From e55881b38be0f7e31e4969d35f44f20c73bad7d5 Mon Sep 17 00:00:00 2001 From: AlacrisDevs Date: Sat, 7 Feb 2026 01:31:55 +0200 Subject: [PATCH] Mega push vol 5, working on messaging now --- .env.example | 5 + .github/workflows/ci.yml | 31 + .gitignore | 5 + AUDIT.md | 92 +- messages/en.json | 255 ++++ messages/et.json | 255 ++++ package-lock.json | 1093 ++++++++++++++- package.json | 6 +- playwright.config.ts | 32 + project.inlang/settings.json | 15 + src/app.html | 13 +- src/hooks.server.ts | 64 +- src/hooks.ts | 3 + src/lib/api/activity.ts | 38 + src/lib/api/calendar.test.ts | 90 ++ src/lib/api/document-locks.ts | 25 +- src/lib/api/documents.test.ts | 132 ++ src/lib/api/documents.ts | 40 +- src/lib/api/google-calendar-push.ts | 218 +++ src/lib/api/google-calendar.test.ts | 61 + src/lib/api/google-calendar.ts | 5 +- src/lib/api/kanban.ts | 162 ++- src/lib/components/calendar/Calendar.svelte | 20 +- .../documents/DocumentViewer.svelte | 2 +- .../components/documents/FileBrowser.svelte | 300 +++-- .../components/kanban/CardDetailModal.svelte | 263 +++- src/lib/components/kanban/KanbanBoard.svelte | 166 ++- src/lib/components/kanban/KanbanCard.svelte | 4 +- .../settings/SettingsIntegrations.svelte | 369 ++++++ .../settings/SettingsMembers.svelte | 398 ++++++ .../components/settings/SettingsRoles.svelte | 350 +++++ src/lib/components/settings/index.ts | 3 + src/lib/components/ui/ContextMenu.svelte | 104 ++ src/lib/components/ui/IconButton.svelte | 32 +- src/lib/components/ui/KanbanColumn.svelte | 2 +- src/lib/components/ui/Logo.svelte | 75 +- src/lib/components/ui/Modal.svelte | 2 +- src/lib/components/ui/PageSkeleton.svelte | 119 ++ src/lib/components/ui/index.ts | 2 + src/lib/types/layout.ts | 29 + src/lib/utils/logger.test.ts | 114 ++ src/lib/utils/logger.ts | 22 +- src/lib/utils/permissions.test.ts | 114 ++ src/lib/utils/permissions.ts | 116 ++ src/routes/+layout.svelte | 14 +- src/routes/[orgSlug]/+layout.server.ts | 94 +- src/routes/[orgSlug]/+layout.svelte | 231 +++- src/routes/[orgSlug]/+page.svelte | 354 ++++- src/routes/[orgSlug]/account/+page.server.ts | 29 + src/routes/[orgSlug]/account/+page.svelte | 485 +++++++ src/routes/[orgSlug]/calendar/+page.server.ts | 3 +- src/routes/[orgSlug]/calendar/+page.svelte | 601 ++++++++- .../[orgSlug]/documents/+page.server.ts | 5 +- .../[orgSlug]/documents/[id]/+page.server.ts | 3 +- .../documents/file/[id]/+page.server.ts | 3 +- .../documents/file/[id]/+page.svelte | 63 +- .../documents/folder/[id]/+page.server.ts | 7 +- src/routes/[orgSlug]/kanban/+page.server.ts | 3 +- src/routes/[orgSlug]/kanban/+page.svelte | 340 ++++- src/routes/[orgSlug]/settings/+page.server.ts | 53 +- src/routes/[orgSlug]/settings/+page.svelte | 1166 ++++------------- .../api/google-calendar/events/+server.ts | 37 +- .../api/google-calendar/push/+server.ts | 191 +++ src/routes/api/release-lock/+server.ts | 41 + src/routes/auth/callback/+server.ts | 15 + src/routes/invite/[token]/+page.server.ts | 4 +- src/routes/invite/[token]/+page.svelte | 8 +- src/routes/layout.css | 8 +- src/routes/login/+page.svelte | 43 +- .../migrations/018_user_avatars_storage.sql | 17 + .../019_fix_org_members_profiles_fk.sql | 8 + tests/e2e/app.spec.ts | 147 +++ tests/e2e/auth.setup.ts | 18 + tests/e2e/cleanup.ts | 191 +++ tests/e2e/features.spec.ts | 550 ++++++++ tests/e2e/helpers.ts | 41 + vite.config.ts | 13 +- 77 files changed, 8478 insertions(+), 1554 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 messages/en.json create mode 100644 messages/et.json create mode 100644 playwright.config.ts create mode 100644 project.inlang/settings.json create mode 100644 src/hooks.ts create mode 100644 src/lib/api/activity.ts create mode 100644 src/lib/api/calendar.test.ts create mode 100644 src/lib/api/documents.test.ts create mode 100644 src/lib/api/google-calendar-push.ts create mode 100644 src/lib/api/google-calendar.test.ts create mode 100644 src/lib/components/settings/SettingsIntegrations.svelte create mode 100644 src/lib/components/settings/SettingsMembers.svelte create mode 100644 src/lib/components/settings/SettingsRoles.svelte create mode 100644 src/lib/components/ui/ContextMenu.svelte create mode 100644 src/lib/components/ui/PageSkeleton.svelte create mode 100644 src/lib/types/layout.ts create mode 100644 src/lib/utils/logger.test.ts create mode 100644 src/lib/utils/permissions.test.ts create mode 100644 src/lib/utils/permissions.ts create mode 100644 src/routes/[orgSlug]/account/+page.server.ts create mode 100644 src/routes/[orgSlug]/account/+page.svelte create mode 100644 src/routes/api/google-calendar/push/+server.ts create mode 100644 src/routes/api/release-lock/+server.ts create mode 100644 supabase/migrations/018_user_avatars_storage.sql create mode 100644 supabase/migrations/019_fix_org_members_profiles_fk.sql create mode 100644 tests/e2e/app.spec.ts create mode 100644 tests/e2e/auth.setup.ts create mode 100644 tests/e2e/cleanup.ts create mode 100644 tests/e2e/features.spec.ts create mode 100644 tests/e2e/helpers.ts diff --git a/.env.example b/.env.example index de3201c..6de9bea 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,8 @@ PUBLIC_SUPABASE_URL=your_supabase_url PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key GOOGLE_API_KEY=your_google_api_key + +# Google Service Account for Calendar push (create/update/delete events) +# Paste the full JSON key file contents, or base64-encode it +# The calendar must be shared with the service account email (with "Make changes to events" permission) +GOOGLE_SERVICE_ACCOUNT_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0858e58 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint (svelte-check) + run: npm run check + + - name: Unit tests + run: npx vitest run --project server + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore index 48052c3..8ee777b 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,8 @@ lerna-debug.log* coverage .nyc_output *.lcov +tests/e2e/.auth/ +test-results/ # Typescript *.tsbuildinfo @@ -89,3 +91,6 @@ supabase/.temp # Sentry .sentryclirc +# Paraglide +src/lib/paraglide +project.inlang/cache/ diff --git a/AUDIT.md b/AUDIT.md index f92ea16..c1eb398 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,10 +1,12 @@ -# Comprehensive Codebase Audit Report (v2) +# Comprehensive Codebase Audit Report (v4) **Project:** root-org (SvelteKit + Supabase + Tailwind v4) -**Date:** 2026-02-06 (updated) +**Date:** 2026-02-06 (v4 update) **Auditor:** Cascade > **Changes since v1:** Dead stores (auth, organizations, documents, kanban, theme) deleted. `OrgWithRole` moved to `$lib/api/organizations.ts`. `FileTree` removed. Documents pages refactored into shared `FileBrowser` component. Document locking added (`document-locks` API + migration). Calendar `$derived` bugs fixed. `buildDocumentTree`/`DocumentWithChildren` removed. Editor CSS typo fixed. Invite page routes corrected. KanbanBoard button label fixed. +> +> **Changes in v4:** Type safety (shared `OrgLayoutData`, `as any` casts fixed, `role`→`userRole` dedup). Architecture (settings page split into 4 components, FileBrowser migrated to API modules, `createDocument` supports kanban). Performance (folder listings exclude content, kanban queries parallelized, card moves batched, realtime incremental). Testing (43 unit tests, expanded E2E coverage, GitHub Actions CI). --- @@ -657,10 +659,84 @@ expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), 3. **Continue splitting settings page** (A-1) — Members, Roles, and Integrations tabs still inline. Extract each into its own component. -### Suggested Order of Operations (updated) +### Resolved Since v3 -1. **Immediate (security):** S-1 (rotate keys — manual), S-4 (server-side auth for settings mutations) -2. **Type safety (1 hour):** T-1 (regenerate Supabase types), T-2→T-5 (fix remaining type issues) -3. **Architecture (1-2 days):** A-1 (finish splitting settings tabs), A-2 (migrate FileBrowser to use API modules), A-3 (add kanban type to createDocument) -4. **Performance (1 day):** P-1 (select only needed columns), P-2 (parallelize kanban queries), P-4 (incremental realtime updates) -5. **Polish:** E-5 (reliable lock release), M-1→M-3 (constants, consistent patterns), F-1 (permission enforcement), F-2 (scoped subscriptions) +| ID | Issue | Resolution | +|----|-------|------------| +| — | Icon buttons not round | All inline icon buttons (`rounded-lg`) changed to `rounded-full` across KanbanCard, KanbanBoard, DocumentViewer, Calendar, Modal, ContextMenu | +| — | Add column/card buttons missing plus icon | Replaced inline buttons with `Button` component using `icon="add"` prop | +| — | Kanban columns not reorderable | Added column drag-and-drop with grip handle, drop indicators, and DB persistence | +| — | Inconsistent cursor styles | Added global CSS rules: `cursor-pointer` on all `button`/`a`/`[role="button"]`, `cursor-grab` on `[draggable="true"]` | +| — | Blurred spinner loading overlay | Replaced `backdrop-blur-sm` spinner with context-aware `PageSkeleton` component (kanban/files/calendar/settings/default variants) | +| — | Language switcher missing | Added locale picker (English/Eesti) to account settings using Paraglide `setLocale()` | +| — | File browser view mode not persisted | Confirmed already working via `localStorage` (`root:viewMode` key) | + +### Resolved Since v4 + +| ID | Issue | Resolution | +|----|-------|------------| +| T-1 | 2 remaining `as any` casts | Replaced with properly typed casts in invite page and CardDetailModal | +| T-2→T-5 | Untyped parent layout data | Created shared `OrgLayoutData` type in `$lib/types/layout.ts`; applied across all 8 page servers | +| R-4 | Duplicate `role`/`userRole` | Removed `role` from layout server return; migrated all consumers to `userRole` | +| A-1 | Settings page god component (1200+ lines) | Extracted `SettingsMembers`, `SettingsRoles`, `SettingsIntegrations` into `$lib/components/settings/`; page reduced to ~470 lines | +| A-2 | FileBrowser direct Supabase calls | Migrated all CRUD operations to use `$lib/api/documents.ts` (`moveDocument`, `updateDocument`, `deleteDocument`, `createDocument`, `copyDocument`) | +| A-3 | `createDocument` missing kanban type | Added `'kanban'` to type union with optional `id` and `content` params | +| E-3 | Calendar date click no-op | Already implemented — clicking a day opens create event modal pre-filled with date | +| P-1 | Folder listings fetch `select('*')` | Changed to select only metadata columns, excluding heavy `content` JSON | +| P-2 | Kanban queries sequential | Board+columns now fetched in parallel; tags+checklists+assignees fetched in parallel | +| P-3 | `moveCard` fires N updates | Now skips cards whose position didn't change — typically 2-3 updates instead of N | +| P-4 | Realtime full board reload | Upgraded `subscribeToBoard` to pass granular payloads; kanban page applies INSERT/UPDATE/DELETE diffs incrementally | +| T6 | No unit tests | Added 43 Vitest unit tests: `logger.test.ts` (10), `google-calendar.test.ts` (11), `calendar.test.ts` (12), `documents.test.ts` (10) | +| T6 | Incomplete E2E coverage | Added Playwright tests for Tags tab, calendar CRUD (create/view/delete), kanban card CRUD (create/detail modal) | +| T6 | No CI pipeline | Created `.github/workflows/ci.yml`: lint → check → unit tests → build | +| T6 | Test cleanup incomplete | Updated `cleanup.ts` to handle test tags, calendar events, and new board prefixes | + +--- + +## Area Scores (v4) + +Scores reflect the current state of the codebase after all v1–v4 fixes. + +| Area | Score | Notes | +|------|-------|-------| +| **Security** | ⭐⭐⭐ 3/5 | S-2, S-3, S-5 fixed. **S-1 (credential rotation) and S-4 (server-side auth for mutations) remain critical/high.** S-6 (lock cleanup race) still open. | +| **Type Safety** | ⭐⭐⭐⭐ 4/5 | `OrgLayoutData` shared type eliminates parent casts. 2 targeted `as any` casts fixed. Remaining `as any` casts are in Supabase join results that need full type regeneration (T-1). | +| **Dead Code** | ⭐⭐⭐⭐⭐ 5/5 | All dead stores, unused components, placeholder tests, empty files, and unused dependencies removed in v2. No known dead code remains. | +| **Architecture** | ⭐⭐⭐⭐ 4/5 | Settings page split into 4 components. FileBrowser migrated to API modules. `createDocument` supports all types. Remaining: some components still have inline Supabase calls (CardDetailModal, CardComments). | +| **Performance** | ⭐⭐⭐⭐ 4/5 | Folder listings exclude content. Kanban queries parallelized. Card moves batched smartly. Realtime is incremental. Remaining: full org document fetch for breadcrumbs could be optimized further. | +| **Error Handling** | ⭐⭐⭐⭐ 4/5 | `alert()` replaced with toasts. Structured logger adopted in API routes. `$effect` sync blocks added. Remaining: `console.error` in 3-4 files (calendar page, invite page), lock release in `onDestroy`. | +| **Testing** | ⭐⭐⭐⭐ 4/5 | 43 unit tests (logger, calendar, google-calendar, documents API). 35+ Playwright E2E tests covering all major flows. CI pipeline on GitHub Actions. Remaining: visual regression tests, Svelte component tests. | +| **Code Quality** | ⭐⭐⭐⭐ 4/5 | Consistent API module pattern. Shared types. i18n complete. Duplication eliminated. Remaining: `role`/`userRole` fully migrated but some inline SVGs and magic numbers persist. | +| **Dependencies** | ⭐⭐⭐⭐⭐ 5/5 | `lucide-svelte` removed. All deps actively used. No known unused packages. | +| **Future-Proofing** | ⭐⭐⭐ 3/5 | Permission system defined but not enforced (F-1). Kanban realtime subscription unscoped (F-2). No search, notifications, or keyboard shortcuts yet. | + +### Overall Score: ⭐⭐⭐⭐ 4.0 / 5 + +**Breakdown:** 41 out of 50 possible stars across 10 areas. + +### Remaining High-Priority Items + +1. **S-1: Rotate credentials & purge `.env` from git history** — Critical security risk. Must be done manually. +2. **S-4: Server-side auth for settings mutations** — Move destructive operations to SvelteKit form actions with explicit authorization. +3. **T-1: Regenerate Supabase types** — `supabase gen types typescript` to eliminate remaining `as any` casts from join results. +4. **F-1: Permission enforcement** — Create `hasPermission()` utility; the permission system is defined but never checked. + +### Remaining Medium-Priority Items + +5. **S-6: Lock cleanup race condition** — Consolidate to server-side cron only. +6. **E-2: Replace remaining `console.*` calls** — 3-4 files still use raw console instead of structured logger. +7. **E-5: Lock release in `onDestroy`** — Use `navigator.sendBeacon` for reliable cleanup. +8. **F-2: Scoped realtime subscriptions** — Filter kanban card changes to current board's columns. +9. **M-1/M-3: Magic numbers and inline SVGs** — Extract constants, use Icon component consistently. + +### Feature Backlog (Tier 5) + +10. Notifications system (mentions, assignments, due dates) +11. Global search across documents, kanban cards, calendar events +12. Keyboard shortcuts for common actions +13. Mobile responsive layout (sidebar drawer, touch-friendly kanban) +14. Dark/light theme toggle +15. Export/import (CSV/JSON/Markdown) +16. Undo/redo with toast-based undo for destructive actions +17. Onboarding flow for new users +18. Visual regression tests for key pages diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..bdd2a21 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,255 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "app_name": "Root", + "nav_files": "Files", + "nav_calendar": "Calendar", + "nav_settings": "Settings", + "nav_kanban": "Kanban", + "user_menu_account_settings": "Account Settings", + "user_menu_switch_org": "Switch Organization", + "user_menu_logout": "Log Out", + "btn_new": "+ New", + "btn_create": "Create", + "btn_cancel": "Cancel", + "btn_save": "Save", + "btn_delete": "Delete", + "btn_edit": "Edit", + "btn_close": "Close", + "btn_upload": "Upload", + "btn_remove": "Remove", + "login_title": "Welcome to Root", + "login_subtitle": "Your team's workspace for docs, boards, and calendars.", + "login_tab_login": "Log In", + "login_tab_signup": "Sign Up", + "login_email_label": "Email", + "login_email_placeholder": "you@example.com", + "login_password_label": "Password", + "login_password_placeholder": "••••••••", + "login_btn_login": "Log In", + "login_btn_signup": "Sign Up", + "login_or_continue": "or continue with", + "login_google": "Continue with Google", + "login_signup_prompt": "Don't have an account?", + "login_login_prompt": "Already have an account?", + "login_signup_success_title": "Check your email", + "login_signup_success_text": "We sent a confirmation link to {email}. Click it to activate your account.", + "org_selector_title": "Your Organizations", + "org_selector_create": "Create Organization", + "org_selector_create_title": "Create Organization", + "org_selector_name_label": "Organization Name", + "org_selector_name_placeholder": "My Team", + "org_selector_slug_label": "URL Slug", + "org_selector_slug_placeholder": "my-team", + "org_overview": "Organization Overview", + "files_title": "Files", + "files_breadcrumb_home": "Home", + "files_create_title": "Create New", + "files_type_document": "Document", + "files_type_folder": "Folder", + "files_type_kanban": "Kanban", + "files_name_label": "Name", + "files_doc_placeholder": "Document name", + "files_folder_placeholder": "Folder name", + "files_kanban_placeholder": "Kanban board name", + "files_rename_title": "Rename", + "files_context_rename": "Rename", + "files_context_move": "Move to...", + "files_context_delete": "Delete", + "files_context_open_tab": "Open in new tab", + "files_empty": "No files yet. Click + New to create one.", + "files_toggle_view": "Toggle view", + "kanban_title": "Kanban", + "kanban_create_board": "Create Board", + "kanban_board_name_label": "Board Name", + "kanban_board_name_placeholder": "e.g. Sprint 1", + "kanban_edit_board": "Edit Board", + "kanban_rename_board": "Rename Board", + "kanban_delete_board": "Delete Board", + "kanban_add_column": "Add Column", + "kanban_column_name_label": "Column Name", + "kanban_column_name_placeholder": "e.g. To Do, In Progress, Done", + "kanban_add_card": "Add Card", + "kanban_card_details": "Card Details", + "kanban_card_title_label": "Title", + "kanban_card_title_placeholder": "Card title", + "kanban_card_desc_label": "Description", + "kanban_card_desc_placeholder": "Add a more detailed description...", + "kanban_tags": "Tags", + "kanban_tag_placeholder": "Tag name", + "kanban_tag_add": "Add", + "kanban_empty": "Kanban boards are now managed in Files", + "kanban_go_to_files": "Go to Files", + "kanban_select_board": "Select a board above", + "calendar_title": "Calendar", + "calendar_subscribe": "Subscribe to Calendar", + "calendar_refresh": "Refresh Events", + "calendar_settings": "Calendar Settings", + "calendar_create_event": "Create Event", + "calendar_edit_event": "Edit Event", + "calendar_event_title": "Title", + "calendar_event_title_placeholder": "Event title", + "calendar_event_date": "Date", + "calendar_event_time": "Time", + "calendar_event_desc": "Description", + "settings_title": "Settings", + "settings_tab_general": "General", + "settings_tab_members": "Members", + "settings_tab_roles": "Roles", + "settings_tab_tags": "Tags", + "settings_tab_integrations": "Integrations", + "settings_general_title": "Organization details", + "settings_general_avatar": "Avatar", + "settings_general_name": "Name", + "settings_general_name_placeholder": "Organization name", + "settings_general_slug": "URL slug (yoursite.com/...)", + "settings_general_slug_placeholder": "my-org", + "settings_general_save": "Save Changes", + "settings_general_danger_zone": "Danger Zone", + "settings_general_delete_org": "Delete Organization", + "settings_general_delete_org_desc": "Permanently delete this organization and all its data.", + "settings_general_leave_org": "Leave Organization", + "settings_general_leave_org_desc": "Leave this organization. You will need to be re-invited to rejoin.", + "settings_general_leave_btn": "Leave {orgName}", + "settings_tags_title": "Organization Tags", + "settings_tags_desc": "Manage tags that can be used across all Kanban boards.", + "settings_tags_create": "Create Tag", + "settings_tags_empty": "No tags yet. Create your first tag to organize Kanban cards.", + "settings_tags_name_placeholder": "Tag name", + "settings_members_title": "Team Members ({count})", + "settings_members_invite": "Invite Member", + "settings_members_pending": "Pending Invites", + "settings_members_invited_as": "Invited as {role} • Expires {date}", + "settings_members_copy_link": "Copy Link", + "settings_members_unknown": "Unknown User", + "settings_members_no_name": "No name", + "settings_members_no_email": "No email", + "settings_members_remove": "Remove from Org", + "settings_invite_title": "Invite Member", + "settings_invite_email": "Email address", + "settings_invite_email_placeholder": "colleague@example.com", + "settings_invite_role": "Role", + "settings_invite_send": "Send Invite", + "settings_invite_role_viewer": "Viewer - Can view content", + "settings_invite_role_commenter": "Commenter - Can view and comment", + "settings_invite_role_editor": "Editor - Can create and edit content", + "settings_invite_role_admin": "Admin - Can manage members and settings", + "settings_edit_member": "Edit Member", + "settings_roles_title": "Roles", + "settings_roles_desc": "Create custom roles with specific permissions.", + "settings_roles_create": "Create Role", + "settings_roles_edit": "Edit Role", + "settings_roles_system": "System", + "settings_roles_default": "Default", + "settings_roles_all_perms": "All Permissions", + "settings_roles_more": "+{count} more", + "settings_roles_name_label": "Name", + "settings_roles_name_placeholder": "e.g., Moderator", + "settings_roles_color": "Color", + "settings_roles_permissions": "Permissions", + "settings_integrations_google_cal": "Google Calendar", + "settings_integrations_google_cal_desc": "Share a Google Calendar with all organization members.", + "settings_integrations_connected": "Connected", + "settings_integrations_disconnect": "Disconnect", + "settings_integrations_connect": "Connect Google Calendar", + "settings_integrations_discord": "Discord", + "settings_integrations_discord_desc": "Get notifications in your Discord server.", + "settings_integrations_slack": "Slack", + "settings_integrations_slack_desc": "Get notifications in your Slack workspace.", + "settings_integrations_coming_soon": "Coming soon", + "settings_connect_cal_title": "Connect Public Google Calendar", + "settings_connect_cal_desc": "Paste your Google Calendar's shareable link or calendar ID. The calendar must be set to public in Google Calendar settings.", + "settings_connect_cal_how": "How to get your calendar link:", + "settings_connect_cal_step1": "Open Google Calendar", + "settings_connect_cal_step2": "Click the 3 dots next to your calendar → Settings", + "settings_connect_cal_step3": "Under \"Access permissions\", check \"Make available to public\"", + "settings_connect_cal_step4": "Scroll to \"Integrate calendar\" and copy the Calendar ID or Public URL", + "settings_connect_cal_input_label": "Calendar URL or ID", + "settings_connect_cal_input_placeholder": "Paste calendar URL or ID (e.g., abc123@group.calendar.google.com)", + "settings_connect_cal_btn": "Connect", + "account_title": "Account Settings", + "account_subtitle": "Manage your personal profile and preferences.", + "account_profile": "Profile", + "account_photo": "Photo", + "account_sync_google": "Sync Google", + "account_remove_photo": "Remove photo", + "account_display_name": "Display Name", + "account_display_name_placeholder": "Your name", + "account_email": "Email", + "account_save_profile": "Save Profile", + "account_appearance": "Appearance", + "account_theme": "Theme", + "account_theme_dark": "Dark", + "account_theme_light": "Light (Coming Soon)", + "account_theme_system": "System (Coming Soon)", + "account_accent_color": "Accent Color", + "account_use_org_theme": "Use Organization Theme", + "account_use_org_theme_desc": "Override your personal theme with the org's settings.", + "account_language": "Language", + "account_language_desc": "Choose your preferred language for the interface.", + "account_save_preferences": "Save Preferences", + "account_security": "Security & Sessions", + "account_password": "Password", + "account_password_desc": "If you signed in with Google, your password is managed by Google. Otherwise, you can reset your password via email.", + "account_send_reset": "Send Reset Email", + "account_active_sessions": "Active Sessions", + "account_sessions_desc": "Sign out of all other sessions if you suspect unauthorized access.", + "account_signout_others": "Sign Out Other Sessions", + "editor_save": "Save", + "editor_saving": "Saving...", + "editor_saved": "Saved", + "editor_error": "Error", + "editor_placeholder": "Start writing...", + "editor_bold": "Bold (Ctrl+B)", + "editor_italic": "Italic (Ctrl+I)", + "editor_strikethrough": "Strikethrough", + "editor_bullet_list": "Bullet List", + "editor_numbered_list": "Numbered List", + "editor_quote": "Quote", + "editor_code_block": "Code Block", + "toast_error_delete_org": "Failed to delete organization.", + "toast_error_leave_org": "Failed to leave organization.", + "toast_error_invite": "Failed to send invite: {error}", + "toast_error_update_role": "Failed to update role.", + "toast_error_remove_member": "Failed to remove member.", + "toast_error_delete_role": "Failed to delete role.", + "toast_error_disconnect_cal": "Failed to disconnect calendar.", + "toast_error_reset_email": "Failed to send reset email.", + "toast_success_reset_email": "Password reset email sent.", + "toast_success_signout_others": "Other sessions signed out.", + "confirm_delete_role": "Delete role \"{name}\"? Members with this role will need to be reassigned.", + "confirm_leave_org": "Are you sure you want to leave {orgName}?", + "confirm_delete_org": "Type \"{orgName}\" to confirm deletion:", + "confirm_remove_member": "Remove {name} from the organization?", + "confirm_disconnect_cal": "Disconnect Google Calendar?", + "role_viewer": "Viewer", + "role_commenter": "Commenter", + "role_editor": "Editor", + "role_admin": "Admin", + "role_owner": "Owner", + "error_owner_cant_leave": "Owners cannot leave. Transfer ownership first or delete the organization.", + "overview_title": "Organization Overview", + "overview_stat_members": "Members", + "overview_stat_documents": "Documents", + "overview_stat_folders": "Folders", + "overview_stat_boards": "Boards", + "overview_quick_links": "Quick Links", + "activity_title": "Recent Activity", + "activity_empty": "No recent activity yet.", + "activity_created": "{user} created {entityType} \"{name}\"", + "activity_updated": "{user} updated {entityType} \"{name}\"", + "activity_deleted": "{user} deleted {entityType} \"{name}\"", + "activity_moved": "{user} moved {entityType} \"{name}\"", + "activity_renamed": "{user} renamed {entityType} \"{name}\"", + "activity_just_now": "Just now", + "activity_minutes_ago": "{count}m ago", + "activity_hours_ago": "{count}h ago", + "activity_days_ago": "{count}d ago", + "entity_document": "document", + "entity_folder": "folder", + "entity_kanban_board": "board", + "entity_kanban_card": "card", + "entity_kanban_column": "column", + "entity_member": "member", + "entity_role": "role", + "entity_invite": "invite" +} \ No newline at end of file diff --git a/messages/et.json b/messages/et.json new file mode 100644 index 0000000..deba226 --- /dev/null +++ b/messages/et.json @@ -0,0 +1,255 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "app_name": "Root", + "nav_files": "Failid", + "nav_calendar": "Kalender", + "nav_settings": "Seaded", + "nav_kanban": "Kanban", + "user_menu_account_settings": "Konto seaded", + "user_menu_switch_org": "Vaheta organisatsiooni", + "user_menu_logout": "Logi välja", + "btn_new": "+ Uus", + "btn_create": "Loo", + "btn_cancel": "Tühista", + "btn_save": "Salvesta", + "btn_delete": "Kustuta", + "btn_edit": "Muuda", + "btn_close": "Sulge", + "btn_upload": "Laadi üles", + "btn_remove": "Eemalda", + "login_title": "Tere tulemast Rooti", + "login_subtitle": "Sinu meeskonna tööruum dokumentide, tahvlite ja kalendrite jaoks.", + "login_tab_login": "Logi sisse", + "login_tab_signup": "Registreeru", + "login_email_label": "E-post", + "login_email_placeholder": "sina@näide.ee", + "login_password_label": "Parool", + "login_password_placeholder": "••••••••", + "login_btn_login": "Logi sisse", + "login_btn_signup": "Registreeru", + "login_or_continue": "või jätka", + "login_google": "Jätka Google'iga", + "login_signup_prompt": "Pole kontot?", + "login_login_prompt": "On juba konto?", + "login_signup_success_title": "Kontrolli oma e-posti", + "login_signup_success_text": "Saatsime kinnituslingi aadressile {email}. Kliki sellel, et oma konto aktiveerida.", + "org_selector_title": "Sinu organisatsioonid", + "org_selector_create": "Loo organisatsioon", + "org_selector_create_title": "Loo organisatsioon", + "org_selector_name_label": "Organisatsiooni nimi", + "org_selector_name_placeholder": "Minu meeskond", + "org_selector_slug_label": "URL-i lühend", + "org_selector_slug_placeholder": "minu-meeskond", + "org_overview": "Organisatsiooni ülevaade", + "files_title": "Failid", + "files_breadcrumb_home": "Avaleht", + "files_create_title": "Loo uus", + "files_type_document": "Dokument", + "files_type_folder": "Kaust", + "files_type_kanban": "Kanban", + "files_name_label": "Nimi", + "files_doc_placeholder": "Dokumendi nimi", + "files_folder_placeholder": "Kausta nimi", + "files_kanban_placeholder": "Kanban tahvli nimi", + "files_rename_title": "Nimeta ümber", + "files_context_rename": "Nimeta ümber", + "files_context_move": "Teisalda...", + "files_context_delete": "Kustuta", + "files_context_open_tab": "Ava uuel vahelehel", + "files_empty": "Faile pole veel. Kliki + Uus, et luua.", + "files_toggle_view": "Vaheta vaadet", + "kanban_title": "Kanban", + "kanban_create_board": "Loo tahvel", + "kanban_board_name_label": "Tahvli nimi", + "kanban_board_name_placeholder": "nt Sprint 1", + "kanban_edit_board": "Muuda tahvlit", + "kanban_rename_board": "Nimeta tahvel ümber", + "kanban_delete_board": "Kustuta tahvel", + "kanban_add_column": "Lisa veerg", + "kanban_column_name_label": "Veeru nimi", + "kanban_column_name_placeholder": "nt Teha, Töös, Valmis", + "kanban_add_card": "Lisa kaart", + "kanban_card_details": "Kaardi üksikasjad", + "kanban_card_title_label": "Pealkiri", + "kanban_card_title_placeholder": "Kaardi pealkiri", + "kanban_card_desc_label": "Kirjeldus", + "kanban_card_desc_placeholder": "Lisa üksikasjalikum kirjeldus...", + "kanban_tags": "Sildid", + "kanban_tag_placeholder": "Sildi nimi", + "kanban_tag_add": "Lisa", + "kanban_empty": "Kanban tahvleid hallatakse nüüd Failides", + "kanban_go_to_files": "Mine Failidesse", + "kanban_select_board": "Vali tahvel ülalt", + "calendar_title": "Kalender", + "calendar_subscribe": "Telli kalender", + "calendar_refresh": "Värskenda sündmusi", + "calendar_settings": "Kalendri seaded", + "calendar_create_event": "Loo sündmus", + "calendar_edit_event": "Muuda sündmust", + "calendar_event_title": "Pealkiri", + "calendar_event_title_placeholder": "Sündmuse pealkiri", + "calendar_event_date": "Kuupäev", + "calendar_event_time": "Kellaaeg", + "calendar_event_desc": "Kirjeldus", + "settings_title": "Seaded", + "settings_tab_general": "Üldine", + "settings_tab_members": "Liikmed", + "settings_tab_roles": "Rollid", + "settings_tab_tags": "Sildid", + "settings_tab_integrations": "Integratsioonid", + "settings_general_title": "Organisatsiooni andmed", + "settings_general_avatar": "Avatar", + "settings_general_name": "Nimi", + "settings_general_name_placeholder": "Organisatsiooni nimi", + "settings_general_slug": "URL-i lühend (sait.ee/...)", + "settings_general_slug_placeholder": "minu-org", + "settings_general_save": "Salvesta muudatused", + "settings_general_danger_zone": "Ohutsoon", + "settings_general_delete_org": "Kustuta organisatsioon", + "settings_general_delete_org_desc": "Kustuta see organisatsioon ja kõik selle andmed jäädavalt.", + "settings_general_leave_org": "Lahku organisatsioonist", + "settings_general_leave_org_desc": "Lahku sellest organisatsioonist. Tagasi liitumiseks on vaja uut kutset.", + "settings_general_leave_btn": "Lahku {orgName}", + "settings_tags_title": "Organisatsiooni sildid", + "settings_tags_desc": "Halda silte, mida saab kasutada kõigil Kanban tahvlitel.", + "settings_tags_create": "Loo silt", + "settings_tags_empty": "Silte pole veel. Loo oma esimene silt Kanban kaartide korraldamiseks.", + "settings_tags_name_placeholder": "Sildi nimi", + "settings_members_title": "Meeskonna liikmed ({count})", + "settings_members_invite": "Kutsu liige", + "settings_members_pending": "Ootel kutsed", + "settings_members_invited_as": "Kutsutud kui {role} • Aegub {date}", + "settings_members_copy_link": "Kopeeri link", + "settings_members_unknown": "Tundmatu kasutaja", + "settings_members_no_name": "Nimi puudub", + "settings_members_no_email": "E-post puudub", + "settings_members_remove": "Eemalda organisatsioonist", + "settings_invite_title": "Kutsu liige", + "settings_invite_email": "E-posti aadress", + "settings_invite_email_placeholder": "kolleeg@näide.ee", + "settings_invite_role": "Roll", + "settings_invite_send": "Saada kutse", + "settings_invite_role_viewer": "Vaataja - Saab sisu vaadata", + "settings_invite_role_commenter": "Kommenteerija - Saab vaadata ja kommenteerida", + "settings_invite_role_editor": "Toimetaja - Saab sisu luua ja muuta", + "settings_invite_role_admin": "Admin - Saab hallata liikmeid ja seadeid", + "settings_edit_member": "Muuda liiget", + "settings_roles_title": "Rollid", + "settings_roles_desc": "Loo kohandatud rolle kindlate õigustega.", + "settings_roles_create": "Loo roll", + "settings_roles_edit": "Muuda rolli", + "settings_roles_system": "Süsteemne", + "settings_roles_default": "Vaikimisi", + "settings_roles_all_perms": "Kõik õigused", + "settings_roles_more": "+{count} veel", + "settings_roles_name_label": "Nimi", + "settings_roles_name_placeholder": "nt Moderaator", + "settings_roles_color": "Värv", + "settings_roles_permissions": "Õigused", + "settings_integrations_google_cal": "Google'i kalender", + "settings_integrations_google_cal_desc": "Jaga Google'i kalendrit kõigi organisatsiooni liikmetega.", + "settings_integrations_connected": "Ühendatud", + "settings_integrations_disconnect": "Katkesta ühendus", + "settings_integrations_connect": "Ühenda Google'i kalender", + "settings_integrations_discord": "Discord", + "settings_integrations_discord_desc": "Saa teavitusi oma Discordi serveris.", + "settings_integrations_slack": "Slack", + "settings_integrations_slack_desc": "Saa teavitusi oma Slacki tööruumis.", + "settings_integrations_coming_soon": "Tulekul", + "settings_connect_cal_title": "Ühenda avalik Google'i kalender", + "settings_connect_cal_desc": "Kleebi oma Google'i kalendri jagamislink või kalendri ID. Kalender peab olema Google'i kalendri seadetes avalikuks seatud.", + "settings_connect_cal_how": "Kuidas saada kalendri linki:", + "settings_connect_cal_step1": "Ava Google'i kalender", + "settings_connect_cal_step2": "Kliki kalendri kõrval 3 punkti → Seaded", + "settings_connect_cal_step3": "\"Juurdepääsuõiguste\" all märgi \"Tee avalikuks\"", + "settings_connect_cal_step4": "Keri alla \"Kalendri integreerimine\" ja kopeeri kalendri ID või avalik URL", + "settings_connect_cal_input_label": "Kalendri URL või ID", + "settings_connect_cal_input_placeholder": "Kleebi kalendri URL või ID (nt abc123@group.calendar.google.com)", + "settings_connect_cal_btn": "Ühenda", + "account_title": "Konto seaded", + "account_subtitle": "Halda oma isiklikku profiili ja eelistusi.", + "account_profile": "Profiil", + "account_photo": "Foto", + "account_sync_google": "Sünkrooni Google", + "account_remove_photo": "Eemalda foto", + "account_display_name": "Kuvatav nimi", + "account_display_name_placeholder": "Sinu nimi", + "account_email": "E-post", + "account_save_profile": "Salvesta profiil", + "account_appearance": "Välimus", + "account_theme": "Teema", + "account_theme_dark": "Tume", + "account_theme_light": "Hele (tulekul)", + "account_theme_system": "Süsteemne (tulekul)", + "account_accent_color": "Aktsentvärv", + "account_use_org_theme": "Kasuta organisatsiooni teemat", + "account_use_org_theme_desc": "Asenda oma isiklik teema organisatsiooni seadetega.", + "account_language": "Keel", + "account_language_desc": "Vali eelistatud liidese keel.", + "account_save_preferences": "Salvesta eelistused", + "account_security": "Turvalisus ja seansid", + "account_password": "Parool", + "account_password_desc": "Kui logisid sisse Google'iga, haldab sinu parooli Google. Muul juhul saad parooli e-posti teel lähtestada.", + "account_send_reset": "Saada lähtestamise e-kiri", + "account_active_sessions": "Aktiivsed seansid", + "account_sessions_desc": "Logi välja kõigist teistest seanssidest, kui kahtlustad volitamata juurdepääsu.", + "account_signout_others": "Logi teised seansid välja", + "editor_save": "Salvesta", + "editor_saving": "Salvestamine...", + "editor_saved": "Salvestatud", + "editor_error": "Viga", + "editor_placeholder": "Alusta kirjutamist...", + "editor_bold": "Paks (Ctrl+B)", + "editor_italic": "Kursiiv (Ctrl+I)", + "editor_strikethrough": "Läbikriipsutus", + "editor_bullet_list": "Täpploend", + "editor_numbered_list": "Nummerdatud loend", + "editor_quote": "Tsitaat", + "editor_code_block": "Koodiplokk", + "toast_error_delete_org": "Organisatsiooni kustutamine ebaõnnestus.", + "toast_error_leave_org": "Organisatsioonist lahkumine ebaõnnestus.", + "toast_error_invite": "Kutse saatmine ebaõnnestus: {error}", + "toast_error_update_role": "Rolli uuendamine ebaõnnestus.", + "toast_error_remove_member": "Liikme eemaldamine ebaõnnestus.", + "toast_error_delete_role": "Rolli kustutamine ebaõnnestus.", + "toast_error_disconnect_cal": "Kalendri lahtiühendamine ebaõnnestus.", + "toast_error_reset_email": "Lähtestamise e-kirja saatmine ebaõnnestus.", + "toast_success_reset_email": "Parooli lähtestamise e-kiri saadetud.", + "toast_success_signout_others": "Teised seansid välja logitud.", + "confirm_delete_role": "Kustuta roll \"{name}\"? Selle rolliga liikmed tuleb ümber määrata.", + "confirm_leave_org": "Kas oled kindel, et soovid lahkuda {orgName}?", + "confirm_delete_org": "Sisesta \"{orgName}\" kustutamise kinnitamiseks:", + "confirm_remove_member": "Eemalda {name} organisatsioonist?", + "confirm_disconnect_cal": "Katkesta Google'i kalendri ühendus?", + "role_viewer": "Vaataja", + "role_commenter": "Kommenteerija", + "role_editor": "Toimetaja", + "role_admin": "Admin", + "role_owner": "Omanik", + "error_owner_cant_leave": "Omanikud ei saa lahkuda. Kõigepealt anna omandiõigus üle või kustuta organisatsioon.", + "overview_title": "Organisatsiooni ülevaade", + "overview_stat_members": "Liikmed", + "overview_stat_documents": "Dokumendid", + "overview_stat_folders": "Kaustad", + "overview_stat_boards": "Tahvlid", + "overview_quick_links": "Kiirlingid", + "activity_title": "Viimane tegevus", + "activity_empty": "Viimast tegevust pole veel.", + "activity_created": "{user} lõi {entityType} \"{name}\"", + "activity_updated": "{user} uuendas {entityType} \"{name}\"", + "activity_deleted": "{user} kustutas {entityType} \"{name}\"", + "activity_moved": "{user} teisaldas {entityType} \"{name}\"", + "activity_renamed": "{user} nimetas ümber {entityType} \"{name}\"", + "activity_just_now": "Just praegu", + "activity_minutes_ago": "{count} min tagasi", + "activity_hours_ago": "{count}t tagasi", + "activity_days_ago": "{count}p tagasi", + "entity_document": "dokumendi", + "entity_folder": "kausta", + "entity_kanban_board": "tahvli", + "entity_kanban_card": "kaardi", + "entity_kanban_column": "veeru", + "entity_member": "liikme", + "entity_role": "rolli", + "entity_invite": "kutse" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e04eb35..b75ab85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,18 @@ "name": "root-org", "version": "0.0.1", "dependencies": { + "@inlang/paraglide-js": "^2.10.0", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.94.0", "@tiptap/core": "^3.19.0", "@tiptap/extension-placeholder": "^3.19.0", "@tiptap/pm": "^3.19.0", - "@tiptap/starter-kit": "^3.19.0" + "@tiptap/starter-kit": "^3.19.0", + "google-auth-library": "^10.5.0" }, "devDependencies": { + "@inlang/paraglide-js": "^2.10.0", + "@playwright/test": "^1.58.1", "@sveltejs/adapter-node": "^5.5.2", "@sveltejs/kit": "^2.50.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", @@ -475,6 +479,69 @@ "node": ">=18" } }, + "node_modules/@inlang/paraglide-js": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.10.0.tgz", + "integrity": "sha512-3xQveEyZMV9IOLP7Vy9Ttye+Yzryqz6KM06tvVwvmbCPDTdzmFoc34KlREXGpHuBAlxRZGfAhcJKfnSXXQDmXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inlang/recommend-sherlock": "^0.2.1", + "@inlang/sdk": "2.6.2", + "commander": "11.1.0", + "consola": "3.4.0", + "json5": "2.2.3", + "unplugin": "^2.1.2", + "urlpattern-polyfill": "^10.0.0" + }, + "bin": { + "paraglide-js": "bin/run.js" + } + }, + "node_modules/@inlang/recommend-sherlock": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz", + "integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "comment-json": "^4.2.3" + } + }, + "node_modules/@inlang/sdk": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.6.2.tgz", + "integrity": "sha512-eOgAX+eQpHvD/H4BMILc4tZ85XviTlwr/51RKkKUHozVVthj5avUPKP+4N4vcTUrqSscl2atTh9NbNTuvoBN0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lix-js/sdk": "0.4.7", + "@sinclair/typebox": "^0.31.17", + "kysely": "^0.27.4", + "sqlite-wasm-kysely": "0.3.0", + "uuid": "^13.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -525,6 +592,72 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lix-js/sdk": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.7.tgz", + "integrity": "sha512-pRbW+joG12L0ULfMiWYosIW0plmW4AsUdiPCp+Z8rAsElJ+wJ6in58zhD3UwUcd4BNcpldEGjg6PdA7e0RgsDQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@lix-js/server-protocol-schema": "0.1.1", + "dedent": "1.5.1", + "human-id": "^4.1.1", + "js-sha256": "^0.11.0", + "kysely": "^0.27.4", + "sqlite-wasm-kysely": "0.3.0", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@lix-js/sdk/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@lix-js/server-protocol-schema": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz", + "integrity": "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", + "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -984,6 +1117,23 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.31.28", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz", + "integrity": "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sqlite.org/sqlite-wasm": { + "version": "3.48.0-build4", + "resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz", + "integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "sqlite-wasm": "bin/index.js" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2176,6 +2326,39 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2192,6 +2375,13 @@ "node": ">= 0.4" } }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2212,6 +2402,56 @@ "node": ">= 0.4" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2248,6 +2488,49 @@ "node": ">=6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/comment-json": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.5.1.tgz", + "integrity": "sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "core-util-is": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -2255,6 +2538,16 @@ "dev": true, "license": "MIT" }, + "node_modules/consola": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz", + "integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -2265,12 +2558,33 @@ "node": ">= 0.6" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2284,6 +2598,47 @@ "node": ">=4" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -2311,6 +2666,27 @@ "dev": true, "license": "MIT" }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -2405,6 +2781,20 @@ "dev": true, "license": "MIT" }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esrap": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", @@ -2432,6 +2822,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2450,6 +2846,57 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -2475,6 +2922,83 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2482,6 +3006,19 @@ "dev": true, "license": "ISC" }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2495,6 +3032,29 @@ "node": ">= 0.4" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-id": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", + "integrity": "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==", + "dev": true, + "license": "MIT", + "bin": { + "human-id": "dist/cli.js" + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -2520,6 +3080,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", @@ -2537,6 +3106,27 @@ "@types/estree": "*" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -2547,6 +3137,56 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-sha256": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz", + "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -2557,6 +3197,17 @@ "node": ">=6" } }, + "node_modules/kysely": { + "version": "0.27.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.6.tgz", + "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -2840,6 +3491,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2883,6 +3540,30 @@ "mini-svg-data-uri": "cli.js" } }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2903,6 +3584,12 @@ "node": ">=10" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2922,6 +3609,44 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2939,6 +3664,21 @@ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2946,6 +3686,22 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3314,6 +4070,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", @@ -3379,6 +4150,26 @@ "node": ">=6" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/set-cookie-parser": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", @@ -3386,6 +4177,27 @@ "dev": true, "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3393,6 +4205,18 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3418,6 +4242,18 @@ "node": ">=0.10.0" } }, + "node_modules/sqlite-wasm-kysely": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz", + "integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==", + "dev": true, + "dependencies": { + "@sqlite.org/sqlite-wasm": "^3.48.0-build2" + }, + "peerDependencies": { + "kysely": "*" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3432,6 +4268,102 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3616,6 +4548,29 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "dev": true, + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3623,6 +4578,20 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -3836,6 +4805,37 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3853,6 +4853,97 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", diff --git a/package.json b/package.json index cea9930..64d70ed 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "test": "npm run test:unit -- --run" }, "devDependencies": { + "@inlang/paraglide-js": "^2.10.0", + "@playwright/test": "^1.58.1", "@sveltejs/adapter-node": "^5.5.2", "@sveltejs/kit": "^2.50.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", @@ -31,11 +33,13 @@ "vitest-browser-svelte": "^2.0.2" }, "dependencies": { + "@inlang/paraglide-js": "^2.10.0", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.94.0", "@tiptap/core": "^3.19.0", "@tiptap/extension-placeholder": "^3.19.0", "@tiptap/pm": "^3.19.0", - "@tiptap/starter-kit": "^3.19.0" + "@tiptap/starter-kit": "^3.19.0", + "google-auth-library": "^10.5.0" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..3ba5674 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + globalTeardown: './tests/e2e/cleanup.ts', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 1, + reporter: 'list', + timeout: 60000, + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + navigationTimeout: 30000, + }, + projects: [ + { + name: 'setup', + testMatch: /auth\.setup\.ts/, + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'tests/e2e/.auth/user.json', + }, + dependencies: ['setup'], + }, + ], +}); diff --git a/project.inlang/settings.json b/project.inlang/settings.json new file mode 100644 index 0000000..9358285 --- /dev/null +++ b/project.inlang/settings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + }, + "baseLocale": "en", + "locales": [ + "en", + "et" + ] +} diff --git a/src/app.html b/src/app.html index f273cc5..c4fbc02 100644 --- a/src/app.html +++ b/src/app.html @@ -1,11 +1,18 @@ - + + - + + + %sveltekit.head% - + +
%sveltekit.body%
diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 457ca1b..59e7eda 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,15 +1,42 @@ +import { sequence } from '@sveltejs/kit/hooks'; +import { paraglideMiddleware } from '$lib/paraglide/server'; import { createServerClient } from '@supabase/ssr'; import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; import type { Handle, HandleServerError } from '@sveltejs/kit'; import type { Database } from '$lib/supabase/types'; import { createLogger } from '$lib/utils/logger'; -export const handle: Handle = async ({ event, resolve }) => { +const serverLog = createLogger('server.error'); + +export const handleError: HandleServerError = async ({ error, event, status, message }) => { + const errorId = crypto.randomUUID().slice(0, 8); + + serverLog.error(`Unhandled server error [${errorId}]`, { + error, + data: { + errorId, + status, + message, + url: event.url.pathname, + method: event.request.method + } + }); + + return { + message: message || 'An unexpected error occurred', + errorId, + context: `${event.request.method} ${event.url.pathname}`, + code: String(status) + }; +}; + +const originalHandle: Handle = async ({ event, resolve }) => { event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { cookies: { getAll() { return event.cookies.getAll(); }, + setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value, options }) => { event.cookies.set(name, value, { ...options, path: '/' }); @@ -19,18 +46,13 @@ export const handle: Handle = async ({ event, resolve }) => { }); event.locals.safeGetSession = async () => { - const { - data: { session } - } = await event.locals.supabase.auth.getSession(); + const { data: { session } } = await event.locals.supabase.auth.getSession(); if (!session) { return { session: null, user: null }; } - const { - data: { user }, - error - } = await event.locals.supabase.auth.getUser(); + const { data: { user }, error } = await event.locals.supabase.auth.getUser(); if (error) { return { session: null, user: null }; @@ -46,26 +68,12 @@ export const handle: Handle = async ({ event, resolve }) => { }); }; -const serverLog = createLogger('server.error'); +const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { + event.request = request; -export const handleError: HandleServerError = async ({ error, event, status, message }) => { - const errorId = crypto.randomUUID().slice(0, 8); - - serverLog.error(`Unhandled server error [${errorId}]`, { - error, - data: { - errorId, - status, - message, - url: event.url.pathname, - method: event.request.method, - }, + return resolve(event, { + transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale) }); +}); - return { - message: message || 'An unexpected error occurred', - errorId, - context: `${event.request.method} ${event.url.pathname}`, - code: String(status), - }; -}; +export const handle = sequence(originalHandle, handleParaglide); diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..e75600b --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,3 @@ +import { deLocalizeUrl } from '$lib/paraglide/runtime'; + +export const reroute = (request) => deLocalizeUrl(request.url).pathname; diff --git a/src/lib/api/activity.ts b/src/lib/api/activity.ts new file mode 100644 index 0000000..0f46ac2 --- /dev/null +++ b/src/lib/api/activity.ts @@ -0,0 +1,38 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database, Json } from '$lib/supabase/types'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('api.activity'); + +export type ActivityAction = 'create' | 'update' | 'delete' | 'move' | 'rename'; +export type EntityType = 'document' | 'folder' | 'kanban_board' | 'kanban_card' | 'kanban_column' | 'member' | 'role' | 'invite'; + +interface LogActivityParams { + orgId: string; + userId: string; + action: ActivityAction; + entityType: EntityType; + entityId?: string; + entityName?: string; + metadata?: Record; +} + +export async function logActivity( + supabase: SupabaseClient, + params: LogActivityParams +): Promise { + const { error } = await supabase.from('activity_log').insert({ + org_id: params.orgId, + user_id: params.userId, + action: params.action, + entity_type: params.entityType, + entity_id: params.entityId ?? null, + entity_name: params.entityName ?? null, + metadata: (params.metadata ?? {}) as Json, + }); + + if (error) { + // Activity logging should never block the main action — just warn + log.warn('Failed to log activity', { error: { message: error.message } }); + } +} diff --git a/src/lib/api/calendar.test.ts b/src/lib/api/calendar.test.ts new file mode 100644 index 0000000..30f2b83 --- /dev/null +++ b/src/lib/api/calendar.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { getMonthDays, isSameDay, formatTime } from './calendar'; + +describe('getMonthDays', () => { + it('returns exactly 42 days (6 weeks grid)', () => { + const days = getMonthDays(2024, 0); // January 2024 + expect(days).toHaveLength(42); + }); + + it('first day of grid is a Monday', () => { + const days = getMonthDays(2024, 0); // January 2024 + // getDay() returns 0=Sun, 1=Mon + expect(days[0].getDay()).toBe(1); + }); + + it('contains all days of the target month', () => { + const days = getMonthDays(2024, 1); // February 2024 (leap year, 29 days) + const febDays = days.filter( + (d) => d.getMonth() === 1 && d.getFullYear() === 2024, + ); + expect(febDays).toHaveLength(29); + }); + + it('contains all days of a 31-day month', () => { + const days = getMonthDays(2024, 2); // March 2024 + const marchDays = days.filter( + (d) => d.getMonth() === 2 && d.getFullYear() === 2024, + ); + expect(marchDays).toHaveLength(31); + }); + + it('pads with previous month days at the start', () => { + // January 2024 starts on Monday, so no padding needed from December + const days = getMonthDays(2024, 0); + expect(days[0].getDate()).toBe(1); + expect(days[0].getMonth()).toBe(0); + }); + + it('pads with next month days at the end', () => { + const days = getMonthDays(2024, 0); // January 2024 + const lastDay = days[days.length - 1]; + // Last day should be in February + expect(lastDay.getMonth()).toBe(1); + }); + + it('handles December correctly (month 11)', () => { + const days = getMonthDays(2024, 11); + expect(days).toHaveLength(42); + const decDays = days.filter( + (d) => d.getMonth() === 11 && d.getFullYear() === 2024, + ); + expect(decDays).toHaveLength(31); + }); +}); + +describe('isSameDay', () => { + it('returns true for same date', () => { + const a = new Date(2024, 5, 15, 10, 30); + const b = new Date(2024, 5, 15, 22, 0); + expect(isSameDay(a, b)).toBe(true); + }); + + it('returns false for different days', () => { + const a = new Date(2024, 5, 15); + const b = new Date(2024, 5, 16); + expect(isSameDay(a, b)).toBe(false); + }); + + it('returns false for different months', () => { + const a = new Date(2024, 5, 15); + const b = new Date(2024, 6, 15); + expect(isSameDay(a, b)).toBe(false); + }); + + it('returns false for different years', () => { + const a = new Date(2024, 5, 15); + const b = new Date(2025, 5, 15); + expect(isSameDay(a, b)).toBe(false); + }); +}); + +describe('formatTime', () => { + it('returns a string with hours and minutes', () => { + const date = new Date(2024, 0, 1, 14, 30); + const result = formatTime(date); + // Format varies by locale, but should contain "30" for minutes + expect(result).toContain('30'); + expect(result.length).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/api/document-locks.ts b/src/lib/api/document-locks.ts index cd38abf..5c78b7c 100644 --- a/src/lib/api/document-locks.ts +++ b/src/lib/api/document-locks.ts @@ -27,14 +27,7 @@ export async function getLockInfo( const { data: lock } = await supabase .from('document_locks') - .select(` - id, - document_id, - user_id, - locked_at, - last_heartbeat, - profiles:user_id (full_name, email) - `) + .select('id, document_id, user_id, locked_at, last_heartbeat') .eq('document_id', documentId) .gt('last_heartbeat', cutoff) .single(); @@ -43,11 +36,23 @@ export async function getLockInfo( return { isLocked: false, lockedBy: null, lockedByName: null, isOwnLock: false }; } - const profile = (lock as any).profiles; // join type not inferred by Supabase + // Fetch profile separately — document_locks.user_id FK points to auth.users, not profiles + let lockedByName = 'Someone'; + if (lock.user_id) { + const { data: profile } = await supabase + .from('profiles') + .select('full_name, email') + .eq('id', lock.user_id) + .single(); + if (profile) { + lockedByName = profile.full_name || profile.email || 'Someone'; + } + } + return { isLocked: true, lockedBy: lock.user_id, - lockedByName: profile?.full_name || profile?.email || 'Someone', + lockedByName, isOwnLock: lock.user_id === currentUserId, }; } diff --git a/src/lib/api/documents.test.ts b/src/lib/api/documents.test.ts new file mode 100644 index 0000000..49ec815 --- /dev/null +++ b/src/lib/api/documents.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createDocument, updateDocument, deleteDocument, moveDocument, copyDocument, fetchDocuments } from './documents'; + +// Lightweight Supabase mock builder +function mockSupabase(response: { data?: unknown; error?: unknown }) { + const chain: Record = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn().mockReturnValue(chain); + } + // Terminal calls resolve the response + chain['single'] = vi.fn().mockResolvedValue(response); + chain['order'] = vi.fn().mockReturnValue({ ...chain, order: vi.fn().mockResolvedValue(response) }); + // For delete → eq chain (no .select().single()) + const eqAfterDelete = vi.fn().mockResolvedValue(response); + const originalDelete = chain['delete']; + chain['delete'] = vi.fn().mockReturnValue({ eq: eqAfterDelete, in: vi.fn().mockResolvedValue(response) }); + // For update → eq (moveDocument has no .select().single()) + chain['update'] = vi.fn().mockReturnValue({ ...chain, eq: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue(response) }), ...response }) }); + + return chain as any; +} + +function mockSupabaseSuccess(data: unknown) { + return mockSupabase({ data, error: null }); +} + +function mockSupabaseError(message: string) { + return mockSupabase({ data: null, error: { message, code: 'ERROR' } }); +} + +const fakeDoc = { + id: 'doc-1', + org_id: 'org-1', + name: 'Test Doc', + type: 'document' as const, + parent_id: null, + path: null, + position: 0, + content: { type: 'doc', content: [] }, + created_by: 'user-1', + created_at: '2024-01-01', + updated_at: '2024-01-01', +}; + +describe('createDocument', () => { + it('creates a document with default content for type "document"', async () => { + const sb = mockSupabaseSuccess(fakeDoc); + const result = await createDocument(sb, 'org-1', 'Test Doc', 'document', null, 'user-1'); + expect(result).toEqual(fakeDoc); + expect(sb.from).toHaveBeenCalledWith('documents'); + }); + + it('creates a folder with null content', async () => { + const folderDoc = { ...fakeDoc, type: 'folder', content: null }; + const sb = mockSupabaseSuccess(folderDoc); + const result = await createDocument(sb, 'org-1', 'Folder', 'folder', null, 'user-1'); + expect(result.type).toBe('folder'); + }); + + it('creates a kanban document with custom id and content', async () => { + const kanbanDoc = { ...fakeDoc, id: 'board-1', type: 'kanban', content: { type: 'kanban', board_id: 'board-1' } }; + const sb = mockSupabaseSuccess(kanbanDoc); + const result = await createDocument( + sb, 'org-1', 'Board', 'kanban', null, 'user-1', + { id: 'board-1', content: { type: 'kanban', board_id: 'board-1' } }, + ); + expect(result.id).toBe('board-1'); + }); + + it('throws on Supabase error', async () => { + const sb = mockSupabaseError('insert failed'); + await expect(createDocument(sb, 'org-1', 'Fail', 'document', null, 'user-1')) + .rejects.toEqual({ message: 'insert failed', code: 'ERROR' }); + }); +}); + +describe('copyDocument', () => { + it('appends " (copy)" to the document name', async () => { + const copiedDoc = { ...fakeDoc, id: 'doc-2', name: 'Test Doc (copy)' }; + const sb = mockSupabaseSuccess(copiedDoc); + const result = await copyDocument(sb, fakeDoc, 'org-1', 'user-1'); + expect(result.name).toBe('Test Doc (copy)'); + }); + + it('throws on Supabase error', async () => { + const sb = mockSupabaseError('copy failed'); + await expect(copyDocument(sb, fakeDoc, 'org-1', 'user-1')) + .rejects.toEqual({ message: 'copy failed', code: 'ERROR' }); + }); +}); + +describe('deleteDocument', () => { + it('calls delete with correct id', async () => { + const sb = mockSupabase({ data: null, error: null }); + await deleteDocument(sb, 'doc-1'); + expect(sb.from).toHaveBeenCalledWith('documents'); + expect(sb.delete).toHaveBeenCalled(); + }); + + it('throws on Supabase error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'delete failed', code: 'ERROR' } }); + await expect(deleteDocument(sb, 'doc-1')) + .rejects.toEqual({ message: 'delete failed', code: 'ERROR' }); + }); +}); + +describe('fetchDocuments', () => { + it('returns documents array on success', async () => { + const docs = [fakeDoc]; + // fetchDocuments calls .from().select().eq().order().order() — need deeper chain + const orderFn2 = vi.fn().mockResolvedValue({ data: docs, error: null }); + const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 }); + const eqFn = vi.fn().mockReturnValue({ order: orderFn1 }); + const selectFn = vi.fn().mockReturnValue({ eq: eqFn }); + const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any; + + const result = await fetchDocuments(sb, 'org-1'); + expect(result).toEqual(docs); + expect(sb.from).toHaveBeenCalledWith('documents'); + }); + + it('throws on Supabase error', async () => { + const orderFn2 = vi.fn().mockResolvedValue({ data: null, error: { message: 'fetch failed' } }); + const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 }); + const eqFn = vi.fn().mockReturnValue({ order: orderFn1 }); + const selectFn = vi.fn().mockReturnValue({ eq: eqFn }); + const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any; + + await expect(fetchDocuments(sb, 'org-1')).rejects.toEqual({ message: 'fetch failed' }); + }); +}); diff --git a/src/lib/api/documents.ts b/src/lib/api/documents.ts index 5c6bc46..05dcbea 100644 --- a/src/lib/api/documents.ts +++ b/src/lib/api/documents.ts @@ -27,19 +27,26 @@ export async function createDocument( supabase: SupabaseClient, orgId: string, name: string, - type: 'folder' | 'document', + type: 'folder' | 'document' | 'kanban', parentId: string | null = null, - userId: string + userId: string, + options?: { id?: string; content?: import('$lib/supabase/types').Json } ): Promise { + let content: import('$lib/supabase/types').Json | null = options?.content ?? null; + if (!content && type === 'document') { + content = { type: 'doc', content: [] }; + } + const { data, error } = await supabase .from('documents') .insert({ + ...(options?.id ? { id: options.id } : {}), org_id: orgId, name, type, parent_id: parentId, created_by: userId, - content: type === 'document' ? { type: 'doc', content: [] } : null + content, }) .select() .single(); @@ -99,6 +106,33 @@ export async function moveDocument( } +export async function copyDocument( + supabase: SupabaseClient, + doc: Pick, + orgId: string, + userId: string +): Promise { + const { data, error } = await supabase + .from('documents') + .insert({ + org_id: orgId, + name: `${doc.name} (copy)`, + type: doc.type, + parent_id: doc.parent_id, + created_by: userId, + content: doc.content, + }) + .select() + .single(); + + if (error) { + log.error('copyDocument failed', { error, data: { orgId, name: doc.name } }); + throw error; + } + log.info('copyDocument ok', { data: { id: data.id, name: data.name } }); + return data; +} + export function subscribeToDocuments( supabase: SupabaseClient, orgId: string, diff --git a/src/lib/api/google-calendar-push.ts b/src/lib/api/google-calendar-push.ts new file mode 100644 index 0000000..b825a6f --- /dev/null +++ b/src/lib/api/google-calendar-push.ts @@ -0,0 +1,218 @@ +import { GoogleAuth } from 'google-auth-library'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('api.google-calendar-push'); + +const CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3'; +const SCOPES = ['https://www.googleapis.com/auth/calendar.events']; + +/** + * Google Calendar push integration via Service Account. + * + * Setup: + * 1. Create a service account in Google Cloud Console + * 2. Download the JSON key file + * 3. Set GOOGLE_SERVICE_ACCOUNT_KEY env var to the JSON string (or base64-encoded) + * 4. Share the Google Calendar with the service account email (give "Make changes to events" permission) + */ + +interface ServiceAccountCredentials { + client_email: string; + private_key: string; + project_id?: string; +} + +let cachedAuth: GoogleAuth | null = null; + +function getServiceAccountCredentials(keyJson: string): ServiceAccountCredentials { + try { + // Try parsing directly as JSON + const parsed = JSON.parse(keyJson); + return parsed; + } catch { + // Try base64 decode first + try { + const decoded = Buffer.from(keyJson, 'base64').toString('utf-8'); + return JSON.parse(decoded); + } catch { + throw new Error('GOOGLE_SERVICE_ACCOUNT_KEY must be valid JSON or base64-encoded JSON'); + } + } +} + +function getAuth(keyJson: string): GoogleAuth { + if (cachedAuth) return cachedAuth; + + const credentials = getServiceAccountCredentials(keyJson); + cachedAuth = new GoogleAuth({ + credentials: { + client_email: credentials.client_email, + private_key: credentials.private_key, + }, + scopes: SCOPES, + }); + + return cachedAuth; +} + +async function getAccessToken(keyJson: string): Promise { + const auth = getAuth(keyJson); + const client = await auth.getClient(); + const tokenResponse = await client.getAccessToken(); + const token = typeof tokenResponse === 'string' ? tokenResponse : tokenResponse?.token; + if (!token) throw new Error('Failed to get access token from service account'); + return token; +} + +export function getServiceAccountEmail(keyJson: string): string | null { + try { + const creds = getServiceAccountCredentials(keyJson); + return creds.client_email; + } catch { + return null; + } +} + +/** + * Fetch events from a Google Calendar using the service account. + * No need for the calendar to be public — just shared with the service account. + */ +export async function fetchCalendarEventsViaServiceAccount( + keyJson: string, + calendarId: string, + timeMin: Date, + timeMax: Date +): Promise { + const token = await getAccessToken(keyJson); + + const params = new URLSearchParams({ + timeMin: timeMin.toISOString(), + timeMax: timeMax.toISOString(), + singleEvents: 'true', + orderBy: 'startTime', + maxResults: '250', + }); + + const response = await fetch( + `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${params}`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (!response.ok) { + const errorText = await response.text(); + log.error('Failed to fetch calendar events via service account', { + error: errorText, + data: { calendarId }, + }); + throw new Error(`Google Calendar API error (${response.status}): ${errorText}`); + } + + const data = await response.json(); + return data.items ?? []; +} + +export interface GoogleEventPayload { + summary: string; + description?: string | null; + start: { dateTime?: string; date?: string; timeZone?: string }; + end: { dateTime?: string; date?: string; timeZone?: string }; + colorId?: string; +} + +/** + * Create an event in Google Calendar. + * Returns the Google event ID. + */ +export async function pushEventToGoogle( + keyJson: string, + calendarId: string, + event: GoogleEventPayload +): Promise { + const token = await getAccessToken(keyJson); + + const response = await fetch( + `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(event), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + log.error('Failed to create Google Calendar event', { error: errorText, data: { calendarId } }); + throw new Error(`Google Calendar API error (${response.status}): ${errorText}`); + } + + const data = await response.json(); + log.info('Created Google Calendar event', { data: { googleEventId: data.id, calendarId } }); + return data.id; +} + +/** + * Update an existing event in Google Calendar. + */ +export async function updateGoogleEvent( + keyJson: string, + calendarId: string, + googleEventId: string, + event: GoogleEventPayload +): Promise { + const token = await getAccessToken(keyJson); + + const response = await fetch( + `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(googleEventId)}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(event), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + log.error('Failed to update Google Calendar event', { error: errorText, data: { calendarId, googleEventId } }); + throw new Error(`Google Calendar API error (${response.status}): ${errorText}`); + } + + log.info('Updated Google Calendar event', { data: { googleEventId, calendarId } }); +} + +/** + * Delete an event from Google Calendar. + */ +export async function deleteGoogleEvent( + keyJson: string, + calendarId: string, + googleEventId: string +): Promise { + const token = await getAccessToken(keyJson); + + const response = await fetch( + `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(googleEventId)}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + // 410 Gone means already deleted — treat as success + if (!response.ok && response.status !== 410) { + const errorText = await response.text(); + log.error('Failed to delete Google Calendar event', { error: errorText, data: { calendarId, googleEventId } }); + throw new Error(`Google Calendar API error (${response.status}): ${errorText}`); + } + + log.info('Deleted Google Calendar event', { data: { googleEventId, calendarId } }); +} diff --git a/src/lib/api/google-calendar.test.ts b/src/lib/api/google-calendar.test.ts new file mode 100644 index 0000000..4de9fe8 --- /dev/null +++ b/src/lib/api/google-calendar.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { extractCalendarId, getCalendarSubscribeUrl } from './google-calendar'; + +describe('extractCalendarId', () => { + it('returns null for empty input', () => { + expect(extractCalendarId('')).toBeNull(); + }); + + it('returns email-style calendar ID as-is', () => { + expect(extractCalendarId('user@gmail.com')).toBe('user@gmail.com'); + }); + + it('trims whitespace from email-style IDs', () => { + expect(extractCalendarId(' user@gmail.com ')).toBe('user@gmail.com'); + }); + + it('returns group calendar ID as-is', () => { + const id = 'abc123@group.calendar.google.com'; + expect(extractCalendarId(id)).toBe(id); + }); + + it('extracts calendar ID from cid parameter (base64)', () => { + const calId = 'user@gmail.com'; + const encoded = btoa(calId); + const url = `https://calendar.google.com/calendar/u/0?cid=${encoded}`; + expect(extractCalendarId(url)).toBe(calId); + }); + + it('extracts calendar ID from src parameter', () => { + const url = 'https://calendar.google.com/calendar/embed?src=user@gmail.com'; + expect(extractCalendarId(url)).toBe('user@gmail.com'); + }); + + it('extracts calendar ID from ical path', () => { + const url = 'https://calendar.google.com/calendar/ical/user%40gmail.com/public/basic.ics'; + expect(extractCalendarId(url)).toBe('user@gmail.com'); + }); + + it('returns null for non-URL non-email input', () => { + expect(extractCalendarId('random-string')).toBeNull(); + }); + + it('handles URL without recognized parameters', () => { + expect(extractCalendarId('https://example.com/page')).toBeNull(); + }); +}); + +describe('getCalendarSubscribeUrl', () => { + it('generates a subscribe URL with base64-encoded calendar ID', () => { + const calId = 'user@gmail.com'; + const url = getCalendarSubscribeUrl(calId); + expect(url).toContain('https://calendar.google.com/calendar/u/0?cid='); + expect(url).toContain(btoa(calId)); + }); + + it('roundtrips with extractCalendarId', () => { + const calId = 'test@group.calendar.google.com'; + const url = getCalendarSubscribeUrl(calId); + expect(extractCalendarId(url)).toBe(calId); + }); +}); diff --git a/src/lib/api/google-calendar.ts b/src/lib/api/google-calendar.ts index 8415e29..c1c5d9a 100644 --- a/src/lib/api/google-calendar.ts +++ b/src/lib/api/google-calendar.ts @@ -90,9 +90,8 @@ export async function fetchPublicCalendarEvents( ); if (!response.ok) { - const error = await response.text(); - console.error('Google Calendar API error:', error); - throw new Error('Failed to fetch calendar events. Make sure the calendar is set to public.'); + const errorText = await response.text(); + throw new Error(`Failed to fetch calendar events (${response.status}): ${errorText}`); } const data = await response.json(); diff --git a/src/lib/api/kanban.ts b/src/lib/api/kanban.ts index 65f688f..7173c4e 100644 --- a/src/lib/api/kanban.ts +++ b/src/lib/api/kanban.ts @@ -34,30 +34,30 @@ export async function fetchBoardWithColumns( supabase: SupabaseClient, boardId: string ): Promise { - const { data: board, error: boardError } = await supabase - .from('kanban_boards') - .select('*') - .eq('id', boardId) - .single(); + // Fetch board and columns in parallel + const [boardResult, columnsResult] = await Promise.all([ + supabase.from('kanban_boards').select('*').eq('id', boardId).single(), + supabase.from('kanban_columns').select('*').eq('board_id', boardId).order('position'), + ]); - if (boardError) { - log.error('fetchBoardWithColumns failed (board)', { error: boardError, data: { boardId } }); - throw boardError; + if (boardResult.error) { + log.error('fetchBoardWithColumns failed (board)', { error: boardResult.error, data: { boardId } }); + throw boardResult.error; } - if (!board) return null; + if (!boardResult.data) return null; - const { data: columns, error: colError } = await supabase - .from('kanban_columns') - .select('*') - .eq('board_id', boardId) - .order('position'); - - if (colError) { - log.error('fetchBoardWithColumns failed (columns)', { error: colError, data: { boardId } }); - throw colError; + if (columnsResult.error) { + log.error('fetchBoardWithColumns failed (columns)', { error: columnsResult.error, data: { boardId } }); + throw columnsResult.error; } - const columnIds = (columns ?? []).map((c) => c.id); + const board = boardResult.data; + const columns = columnsResult.data ?? []; + const columnIds = columns.map((c) => c.id); + + if (columnIds.length === 0) { + return { ...board, columns: columns.map((col) => ({ ...col, cards: [] })) }; + } const { data: cards, error: cardError } = await supabase .from('kanban_cards') @@ -70,42 +70,71 @@ export async function fetchBoardWithColumns( throw cardError; } - // Fetch tags for all cards in one query const cardIds = (cards ?? []).map((c) => c.id); - let cardTagsMap = new Map(); + const cardTagsMap = new Map(); + const checklistMap = new Map(); + const assigneeMap = new Map(); if (cardIds.length > 0) { - const { data: cardTags } = await supabase - .from('card_tags') - .select('card_id, tags:tag_id (id, name, color)') - .in('card_id', cardIds); + const assigneeIds = [...new Set((cards ?? []).map((c) => c.assignee_id).filter(Boolean))] as string[]; - (cardTags ?? []).forEach((ct: any) => { - const tag = Array.isArray(ct.tags) ? ct.tags[0] : ct.tags; + // Fetch tags, checklists, and assignee profiles in parallel + const [cardTagsResult, checklistResult, profilesResult] = await Promise.all([ + supabase.from('card_tags').select('card_id, tags:tag_id (id, name, color)').in('card_id', cardIds), + supabase.from('kanban_checklist_items').select('card_id, completed').in('card_id', cardIds), + assigneeIds.length > 0 + ? supabase.from('profiles').select('id, full_name, avatar_url').in('id', assigneeIds) + : Promise.resolve({ data: null }), + ]); + + (cardTagsResult.data ?? []).forEach((ct: Record) => { + const rawTags = ct.tags; + const tag = Array.isArray(rawTags) ? rawTags[0] : rawTags; if (!tag) return; - if (!cardTagsMap.has(ct.card_id)) { - cardTagsMap.set(ct.card_id, []); + const cardId = ct.card_id as string; + if (!cardTagsMap.has(cardId)) { + cardTagsMap.set(cardId, []); } - cardTagsMap.get(ct.card_id)!.push(tag); + cardTagsMap.get(cardId)!.push(tag as { id: string; name: string; color: string | null }); + }); + + (checklistResult.data ?? []).forEach((item: Record) => { + const cardId = item.card_id as string; + if (!checklistMap.has(cardId)) { + checklistMap.set(cardId, { total: 0, done: 0 }); + } + const entry = checklistMap.get(cardId)!; + entry.total++; + if (item.completed) entry.done++; + }); + + (profilesResult.data ?? []).forEach((p: Record) => { + assigneeMap.set(p.id as string, { name: p.full_name as string | null, avatar: p.avatar_url as string | null }); }); } - const cardsByColumn = new Map(); + const cardsByColumn = new Map(); (cards ?? []).forEach((card) => { const colId = card.column_id; if (!colId) return; if (!cardsByColumn.has(colId)) { cardsByColumn.set(colId, []); } + const cl = checklistMap.get(card.id); + const assignee = card.assignee_id ? assigneeMap.get(card.assignee_id) : null; cardsByColumn.get(colId)!.push({ ...card, - tags: cardTagsMap.get(card.id) ?? [] + tags: cardTagsMap.get(card.id) ?? [], + checklist_total: cl?.total ?? 0, + checklist_done: cl?.done ?? 0, + assignee_name: assignee?.name ?? null, + assignee_avatar: assignee?.avatar ?? null, }); }); return { ...board, - columns: (columns ?? []).map((col) => ({ + columns: columns.map((col) => ({ ...col, cards: cardsByColumn.get(col.id) ?? [] })) @@ -283,39 +312,76 @@ export async function moveCard( ...otherCards.slice(newPosition), ]; - // Batch update: move card to column + set position, then update siblings - const updates = reordered.map((c, i) => { - if (c.id === cardId) { + // Build a map of old positions to detect what actually changed + const oldPositionMap = new Map((targetCards ?? []).map((c) => [c.id, c.position])); + + // Only update cards whose position or column actually changed + const updates = reordered + .map((c, i) => { + if (c.id === cardId) { + // The moved card always needs updating (column + position) + return supabase + .from('kanban_cards') + .update({ column_id: newColumnId, position: i }) + .eq('id', c.id); + } + // Skip siblings whose position hasn't changed + if (oldPositionMap.get(c.id) === i) return null; return supabase .from('kanban_cards') - .update({ column_id: newColumnId, position: i }) + .update({ position: i }) .eq('id', c.id); - } - return supabase - .from('kanban_cards') - .update({ position: i }) - .eq('id', c.id); - }); + }) + .filter(Boolean); + + if (updates.length === 0) return; const results = await Promise.all(updates); - const failed = results.find((r) => r.error); + const failed = results.find((r) => r && r.error); if (failed?.error) { log.error('moveCard failed', { error: failed.error, data: { cardId, newColumnId, newPosition } }); throw failed.error; } } +export interface RealtimeChangePayload> { + event: 'INSERT' | 'UPDATE' | 'DELETE'; + new: T; + old: Partial; +} + export function subscribeToBoard( supabase: SupabaseClient, boardId: string, - onColumnChange: () => void, - onCardChange: () => void + columnIds: string[], + onColumnChange: (payload: RealtimeChangePayload) => void, + onCardChange: (payload: RealtimeChangePayload) => void ) { const channel = supabase.channel(`kanban:${boardId}`); + const columnIdSet = new Set(columnIds); channel - .on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_columns', filter: `board_id=eq.${boardId}` }, onColumnChange) - .on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_cards' }, onCardChange) + .on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_columns', filter: `board_id=eq.${boardId}` }, + (payload) => onColumnChange({ + event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE', + new: payload.new as KanbanColumn, + old: payload.old as Partial, + }) + ) + .on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_cards' }, + (payload) => { + // Client-side filter: only process cards belonging to this board's columns + const card = (payload.new ?? payload.old) as Partial; + const colId = card.column_id ?? (payload.old as Partial)?.column_id; + if (colId && !columnIdSet.has(colId)) return; + + onCardChange({ + event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE', + new: payload.new as KanbanCard, + old: payload.old as Partial, + }); + } + ) .subscribe(); return channel; diff --git a/src/lib/components/calendar/Calendar.svelte b/src/lib/components/calendar/Calendar.svelte index 8013151..f2bad56 100644 --- a/src/lib/components/calendar/Calendar.svelte +++ b/src/lib/components/calendar/Calendar.svelte @@ -128,7 +128,7 @@
-
+
{#each weeks as week}
{#each week as day} @@ -211,7 +213,7 @@ {@const isToday = isSameDay(day, today)} {@const inMonth = isCurrentMonth(day)}
onDateClick?.(day)} > @@ -254,12 +256,14 @@
-
+
{#each weekDates as day} {@const dayEvents = getEventsForDay(day)} {@const isToday = isSameDay(day, today)}
-
+
+
{#each dayEvents as event} - - (viewMode = viewMode === "list" ? "grid" : "list")} - > + + (showCreateModal = false)} - title="Create New" + title={m.files_create_title()} >
@@ -685,7 +709,7 @@ style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" >description - Document + {m.files_type_document()}
{m.btn_cancel()} {m.btn_create()}
@@ -757,7 +781,7 @@ style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" >edit - Rename + {m.files_context_rename()}
{/if} @@ -849,13 +873,13 @@ editingDoc = null; newDocName = ""; }} - title="Rename" + title={m.files_rename_title()} >
{m.btn_cancel()} {m.btn_save()}
diff --git a/src/lib/components/kanban/CardDetailModal.svelte b/src/lib/components/kanban/CardDetailModal.svelte index f41823a..add8451 100644 --- a/src/lib/components/kanban/CardDetailModal.svelte +++ b/src/lib/components/kanban/CardDetailModal.svelte @@ -100,6 +100,10 @@ let cardTagIds = $state>(new Set()); let newTagName = $state(""); let showTagInput = $state(false); + let editingTagId = $state(null); + let editTagName = $state(""); + let editTagColor = $state(""); + let showTagManager = $state(false); const TAG_COLORS = [ "#00A3E0", @@ -238,6 +242,38 @@ showTagInput = false; } + function startEditTag(tag: OrgTag) { + editingTagId = tag.id; + editTagName = tag.name; + editTagColor = tag.color || TAG_COLORS[0]; + } + + async function saveEditTag() { + if (!editingTagId || !editTagName.trim()) return; + const { error } = await supabase + .from("tags") + .update({ name: editTagName.trim(), color: editTagColor }) + .eq("id", editingTagId); + if (!error) { + orgTags = orgTags.map((t) => + t.id === editingTagId + ? { ...t, name: editTagName.trim(), color: editTagColor } + : t, + ); + } + editingTagId = null; + } + + async function deleteTag(tagId: string) { + if (!confirm("Delete this tag from the organization?")) return; + const { error } = await supabase.from("tags").delete().eq("id", tagId); + if (!error) { + orgTags = orgTags.filter((t) => t.id !== tagId); + cardTagIds.delete(tagId); + cardTagIds = new Set(cardTagIds); + } + } + async function handleSave() { if (!isMounted) return; if (mode === "create") { @@ -282,7 +318,10 @@ .eq("id", columnId) .single(); - const position = (column as any)?.cards?.[0]?.count ?? 0; // join aggregation not typed + const cards = (column as Record | null)?.cards as + | { count: number }[] + | undefined; + const position = cards?.[0]?.count ?? 0; const { data: newCard, error } = await supabase .from("kanban_cards") @@ -300,6 +339,26 @@ .single(); if (!error && newCard) { + // Persist checklist items added during creation + if (checklist.length > 0) { + await supabase.from("kanban_checklist_items").insert( + checklist.map((item, i) => ({ + card_id: newCard.id, + title: item.title, + position: i, + completed: false, + })), + ); + } + // Persist tags assigned during creation + if (cardTagIds.size > 0) { + await supabase.from("card_tags").insert( + [...cardTagIds].map((tagId) => ({ + card_id: newCard.id, + tag_id: tagId, + })), + ); + } onCreate?.(newCard as KanbanCard); onClose(); } @@ -307,7 +366,25 @@ } async function handleAddItem() { - if (!card || !newItemTitle.trim()) return; + if (!newItemTitle.trim()) return; + + if (mode === "create") { + // In create mode, add items locally (no card ID yet) + checklist = [ + ...checklist, + { + id: `temp-${Date.now()}`, + card_id: "", + title: newItemTitle.trim(), + completed: false, + position: checklist.length, + }, + ]; + newItemTitle = ""; + return; + } + + if (!card) return; const position = checklist.length; const { data, error } = await supabase @@ -429,10 +506,118 @@
- Tags +
+ Tags + +
+ + {#if showTagManager} + +
+ {#each orgTags as tag} +
+ {#if editingTagId === tag.id} +
+ + { + if (e.key === "Enter") + saveEditTag(); + if (e.key === "Escape") { + editingTagId = null; + } + }} + /> + + +
+ {:else} + + {tag.name} + + + {/if} +
+ {/each} + +
+ + e.key === "Enter" && createTag()} + /> + +
+
+ {/if} + +
{#each orgTags as tag} + +
+ {:else} - -
- {:else} - + {/if} {/if}
diff --git a/src/lib/components/kanban/KanbanBoard.svelte b/src/lib/components/kanban/KanbanBoard.svelte index fdedc96..6148477 100644 --- a/src/lib/components/kanban/KanbanBoard.svelte +++ b/src/lib/components/kanban/KanbanBoard.svelte @@ -2,6 +2,7 @@ import type { ColumnWithCards } from "$lib/api/kanban"; import type { KanbanCard } from "$lib/supabase/types"; import KanbanCardComponent from "./KanbanCard.svelte"; + import { Button } from "$lib/components/ui"; interface Props { columns: ColumnWithCards[]; @@ -15,6 +16,7 @@ onAddColumn?: () => void; onDeleteCard?: (cardId: string) => void; onDeleteColumn?: (columnId: string) => void; + onRenameColumn?: (columnId: string, newName: string) => void; canEdit?: boolean; } @@ -26,9 +28,37 @@ onAddColumn, onDeleteCard, onDeleteColumn, + onRenameColumn, canEdit = true, }: Props = $props(); + let columnMenuId = $state(null); + let renamingColumnId = $state(null); + let renameValue = $state(""); + + function openColumnMenu(columnId: string) { + columnMenuId = columnMenuId === columnId ? null : columnId; + } + + function startRename(column: ColumnWithCards) { + renameValue = column.name; + renamingColumnId = column.id; + columnMenuId = null; + } + + function confirmRename() { + if (renamingColumnId && renameValue.trim()) { + onRenameColumn?.(renamingColumnId, renameValue.trim()); + } + renamingColumnId = null; + renameValue = ""; + } + + function cancelRename() { + renamingColumnId = null; + renameValue = ""; + } + let draggedCard = $state(null); let dragOverColumn = $state(null); let dragOverCardIndex = $state<{ columnId: string; index: number } | null>( @@ -58,7 +88,6 @@ e.stopPropagation(); if (!draggedCard) return; - // Determine if we're in the top or bottom half of the card const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const midY = rect.top + rect.height / 2; const dropIndex = e.clientY < midY ? index : index + 1; @@ -84,7 +113,6 @@ let newPosition: number; if (targetIndex && targetIndex.columnId === columnId) { newPosition = targetIndex.index; - // If moving within the same column and the card is above the target, adjust if (draggedCard.column_id === columnId) { const currentIndex = column.cards.findIndex( (c) => c.id === draggedCard!.id, @@ -92,7 +120,6 @@ if (currentIndex !== -1 && currentIndex < newPosition) { newPosition = Math.max(0, newPosition - 1); } - // No-op if dropping in the same position if (currentIndex === newPosition) { draggedCard = null; return; @@ -107,10 +134,13 @@ } -
- {#each columns as column} + {/if}
diff --git a/src/lib/components/kanban/KanbanCard.svelte b/src/lib/components/kanban/KanbanCard.svelte index bdbad96..37a6e7a 100644 --- a/src/lib/components/kanban/KanbanCard.svelte +++ b/src/lib/components/kanban/KanbanCard.svelte @@ -67,7 +67,7 @@ {#if ondelete} +
+
+ {:else if !serviceAccountEmail} +
+

+ Setup required +

+

+ A server administrator needs to configure the GOOGLE_SERVICE_ACCOUNT_KEY environment variable before calendars can be connected. +

+
+ {:else} +
+ +
+ {/if} +
+
+
+ + + +
+
+
+ + + +
+
+

Discord

+

+ Get notifications in your Discord server. +

+

Coming soon

+
+
+
+
+ + +
+
+
+ + + +
+
+

Slack

+

+ Get notifications in your Slack workspace. +

+

Coming soon

+
+
+
+
+
+ + + (showConnectModal = false)} + title="Connect Google Calendar" +> +
+

+ Connect any Google Calendar to your organization. Events you create + here will automatically appear in Google Calendar and vice versa. +

+ + + {#if serviceAccountEmail} +
+

+ Step 1: Share your calendar +

+

+ In Google Calendar, go to your calendar's settings → "Share + with specific people" → add this email with "Make changes to events" permission: +

+
+ + {serviceAccountEmail} + + +
+
+ {/if} + + +
+

+ {serviceAccountEmail ? "Step 2" : "Step 1"}: Paste your Calendar + ID +

+

+ In your calendar settings, scroll to "Integrate calendar" and + copy the Calendar ID. +

+
+ + + + {#if calendarError} +

{calendarError}

+ {/if} + +
+ + +
+
+
diff --git a/src/lib/components/settings/SettingsMembers.svelte b/src/lib/components/settings/SettingsMembers.svelte new file mode 100644 index 0000000..7ffa104 --- /dev/null +++ b/src/lib/components/settings/SettingsMembers.svelte @@ -0,0 +1,398 @@ + + +
+
+

+ {m.settings_members_title({ + count: String(members.length), + })} +

+ +
+ + + {#if invites.length > 0} + +
+

+ {m.settings_members_pending()} +

+
+ {#each invites as invite} +
+
+

{invite.email}

+

+ Invited as {invite.role} • Expires {new Date( + invite.expires_at, + ).toLocaleDateString()} +

+
+
+ + +
+
+ {/each} +
+
+
+ {/if} + + + +
+ {#each members as member} + {@const rawProfile = member.profiles} + {@const profile = Array.isArray(rawProfile) + ? rawProfile[0] + : rawProfile} +
+
+
+ {(profile?.full_name || + profile?.email || + "?")[0].toUpperCase()} +
+
+

+ {profile?.full_name || + profile?.email || + "Unknown User"} +

+

+ {profile?.email || "No email"} +

+
+
+
+ {member.role} + {#if member.user_id !== userId && member.role !== "owner"} + + {/if} +
+
+ {/each} +
+
+
+ + + (showInviteModal = false)} + title="Invite Member" +> +
+ + +
+ +
+ + +
+
+
+ {/if} +
diff --git a/src/lib/components/settings/SettingsRoles.svelte b/src/lib/components/settings/SettingsRoles.svelte new file mode 100644 index 0000000..13115b6 --- /dev/null +++ b/src/lib/components/settings/SettingsRoles.svelte @@ -0,0 +1,350 @@ + + +
+
+
+

Roles

+

+ Create custom roles with specific permissions. +

+
+ +
+ +
+ {#each roles as role} + +
+
+
+
+ {role.name} + {#if role.is_system} + System + {/if} + {#if role.is_default} + Default + {/if} +
+
+ {#if !role.is_system || role.name !== "Owner"} + + {/if} + {#if !role.is_system} + + {/if} +
+
+
+ {#if role.permissions.includes("*")} + All Permissions + {:else} + {#each role.permissions.slice(0, 6) as perm} + {perm} + {/each} + {#if role.permissions.length > 6} + +{role.permissions.length - 6} more + {/if} + {/if} +
+
+
+ {/each} +
+
+ + + (showRoleModal = false)} + title={editingRole ? "Edit Role" : "Create Role"} +> +
+ +
+ +
+ {#each roleColors as color} + + {/each} +
+
+
+ +
+ {#each permissionGroups as group} +
+

+ {group.name} +

+
+ {#each group.permissions as perm} + + {/each} +
+
+ {/each} +
+
+
+ + +
+
+
diff --git a/src/lib/components/settings/index.ts b/src/lib/components/settings/index.ts index c44a2a5..86076e1 100644 --- a/src/lib/components/settings/index.ts +++ b/src/lib/components/settings/index.ts @@ -1 +1,4 @@ export { default as SettingsGeneral } from './SettingsGeneral.svelte'; +export { default as SettingsMembers } from './SettingsMembers.svelte'; +export { default as SettingsRoles } from './SettingsRoles.svelte'; +export { default as SettingsIntegrations } from './SettingsIntegrations.svelte'; diff --git a/src/lib/components/ui/ContextMenu.svelte b/src/lib/components/ui/ContextMenu.svelte new file mode 100644 index 0000000..933376f --- /dev/null +++ b/src/lib/components/ui/ContextMenu.svelte @@ -0,0 +1,104 @@ + + +
+ + + {#if isOpen} +
+ {#each items as item} + {#if item.divider} +
+ {/if} + + {/each} +
+ {/if} +
diff --git a/src/lib/components/ui/IconButton.svelte b/src/lib/components/ui/IconButton.svelte index c83e555..e696170 100644 --- a/src/lib/components/ui/IconButton.svelte +++ b/src/lib/components/ui/IconButton.svelte @@ -1,11 +1,11 @@ @@ -47,7 +47,7 @@ {title} aria-label={title} class=" - inline-flex items-center justify-center rounded-lg transition-colors + inline-flex items-center justify-center rounded-full transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed {variantClasses[variant]} {sizeClasses[size]} diff --git a/src/lib/components/ui/KanbanColumn.svelte b/src/lib/components/ui/KanbanColumn.svelte index 17fd233..f604b1b 100644 --- a/src/lib/components/ui/KanbanColumn.svelte +++ b/src/lib/components/ui/KanbanColumn.svelte @@ -54,7 +54,7 @@ {#if onAddCard} - {/if} diff --git a/src/lib/components/ui/Logo.svelte b/src/lib/components/ui/Logo.svelte index 1599c78..8b21308 100644 --- a/src/lib/components/ui/Logo.svelte +++ b/src/lib/components/ui/Logo.svelte @@ -1,39 +1,54 @@ -
- - - - - - - - - - +
+
+ + + + + + +
+ {#if showText} + + Root + + {/if}
diff --git a/src/lib/components/ui/Modal.svelte b/src/lib/components/ui/Modal.svelte index 01fbf18..5dce9dd 100644 --- a/src/lib/components/ui/Modal.svelte +++ b/src/lib/components/ui/Modal.svelte @@ -64,7 +64,7 @@ {title} + +
+ {#if avatarUrl} + + {/if} +
+
+
+ + + + + + + +
+ +
+
+ + +
+

+ {m.account_appearance()} +

+ + + + + colorize + + +
+ + + +
+
+

+ {m.account_use_org_theme()} +

+

+ {m.account_use_org_theme_desc()} +

+
+ +
+ + +
+ {m.account_language()} +

+ {m.account_language_desc()} +

+
+ {#each locales as locale} + + {/each} +
+
+ +
+ +
+ + + +
+

+ {m.account_security()} +

+ +
+

+ {m.account_password()} +

+

+ {m.account_password_desc()} +

+
+ +
+
+ +
+

+ {m.account_active_sessions()} +

+

+ {m.account_sessions_desc()} +

+
+ +
+
+
+ + diff --git a/src/routes/[orgSlug]/calendar/+page.server.ts b/src/routes/[orgSlug]/calendar/+page.server.ts index b1d877c..1389779 100644 --- a/src/routes/[orgSlug]/calendar/+page.server.ts +++ b/src/routes/[orgSlug]/calendar/+page.server.ts @@ -1,10 +1,11 @@ import type { PageServerLoad } from './$types'; +import type { OrgLayoutData } from '$lib/types/layout'; import { createLogger } from '$lib/utils/logger'; const log = createLogger('page.calendar'); export const load: PageServerLoad = async ({ parent, locals }) => { - const { org, userRole } = await parent(); + const { org, userRole } = await parent() as OrgLayoutData; const { supabase } = locals; // Fetch events for current month ± 1 month diff --git a/src/routes/[orgSlug]/calendar/+page.svelte b/src/routes/[orgSlug]/calendar/+page.svelte index 9fe572d..bda44a0 100644 --- a/src/routes/[orgSlug]/calendar/+page.svelte +++ b/src/routes/[orgSlug]/calendar/+page.svelte @@ -1,6 +1,14 @@ @@ -503,7 +279,9 @@
-

Settings

+

+ {m.settings_title()} +

@@ -536,604 +314,166 @@ {#if activeTab === "members"} -
-
-

- Team Members ({members.length}) -

- -
- - - {#if invites.length > 0} - -
-

- Pending Invites -

-
- {#each invites as invite} -
-
-

{invite.email}

-

- Invited as {invite.role} • Expires {new Date( - invite.expires_at, - ).toLocaleDateString()} -

-
-
- - -
-
- {/each} -
-
-
- {/if} - - - -
- {#each members as member} - {@const rawProfile = member.profiles} - {@const profile = Array.isArray(rawProfile) - ? rawProfile[0] - : rawProfile} -
-
-
- {(profile?.full_name || - profile?.email || - "?")[0].toUpperCase()} -
-
-

- {profile?.full_name || - profile?.email || - "Unknown User"} -

-

- {profile?.email || "No email"} -

-
-
-
- {member.role} - {#if member.user_id !== data.user?.id && member.role !== "owner"} - - {/if} -
-
- {/each} -
-
-
+ {/if} {#if activeTab === "roles"} -
+ + {/if} + + + {#if activeTab === "tags"} +
-

Roles

+

+ {m.settings_tags_title()} +

- Create custom roles with specific permissions. + {m.settings_tags_desc()}

-
-
- {#each roles as role} - -
-
+ {#if orgTags.length === 0 && tagsLoaded} + +
+ label +

{m.settings_tags_empty()}

+
+
+ {:else} +
+ {#each orgTags as tag} + +
- {role.name} - {#if role.is_system} Systemlabel - {/if} - {#if role.is_default} - Default - {/if} +
+
+

+ {tag.name} +

+

+ {tag.color || "#00A3E0"} +

+
- {#if !role.is_system || role.name !== "Owner"} - - {/if} - {#if !role.is_system} - - {/if} + +
-
- {#if role.permissions.includes("*")} - All Permissions - {:else} - {#each role.permissions.slice(0, 6) as perm} - {perm} - {/each} - {#if role.permissions.length > 6} - +{role.permissions.length - 6} more - {/if} - {/if} -
-
- - {/each} -
+
+ {/each} +
+ {/if}
{/if} {#if activeTab === "integrations"} -
- -
-
-
- - - - - - -
-
-

- Google Calendar -

-

- Share a Google Calendar with all organization - members. -

- - {#if orgCalendar} -
-
-
-

- Connected -

-

- {orgCalendar.calendar_name || - "Google Calendar"} -

-

- {orgCalendar.calendar_id - .length > 40 - ? orgCalendar.calendar_id.slice( - 0, - 40, - ) + "..." - : orgCalendar.calendar_id} -

-
- -
-
- {:else} -
- -
- {/if} -
-
-
-
- - -
-
-
- - - -
-
-

- Discord -

-

- Get notifications in your Discord server. -

-

- Coming soon -

-
-
-
-
- - -
-
-
- - - -
-
-

- Slack -

-

- Get notifications in your Slack workspace. -

-

- Coming soon -

-
-
-
-
-
+ {/if}
- + (showInviteModal = false)} - title="Invite Member" -> -
- - -
- -
- - -
-
-
- {/if} -
- - - (showRoleModal = false)} - title={editingRole ? "Edit Role" : "Create Role"} + isOpen={showCreateTagModal} + onClose={() => (showCreateTagModal = false)} + title={editingTag ? "Edit Tag" : "Create Tag"} >
- -
- {#each roleColors as color} + Color +
+ {#each TAG_COLORS as color} {/each}
-
-
- -
- {#each permissionGroups as group} -
-

- {group.name} -

-
- {#each group.permissions as perm} - - {/each} -
-
- {/each} +
+ Custom: + + {tagColor}
-
- - + Preview: + + {tagName || "Tag name"} +
-
- - - - (showConnectModal = false)} - title="Connect Public Google Calendar" -> -
-

- Paste your Google Calendar's shareable link or calendar ID. The - calendar must be set to public in Google Calendar settings. -

- -
-

- How to get your calendar link: -

-
    -
  1. Open Google Calendar
  2. -
  3. Click the 3 dots next to your calendar → Settings
  4. -
  5. - Under "Access permissions", check "Make available to public" -
  6. -
  7. - Scroll to "Integrate calendar" and copy the Calendar ID or - Public URL -
  8. -
-
- - - - {#if calendarError} -

{calendarError}

- {/if} -
(showCreateTagModal = false)}>Cancel {editingTag ? "Save" : "Create"}
diff --git a/src/routes/api/google-calendar/events/+server.ts b/src/routes/api/google-calendar/events/+server.ts index cc98321..8cef64d 100644 --- a/src/routes/api/google-calendar/events/+server.ts +++ b/src/routes/api/google-calendar/events/+server.ts @@ -1,7 +1,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { GOOGLE_API_KEY } from '$env/static/private'; +import { env } from '$env/dynamic/private'; import { fetchPublicCalendarEvents } from '$lib/api/google-calendar'; +import { fetchCalendarEventsViaServiceAccount } from '$lib/api/google-calendar-push'; import { createLogger } from '$lib/utils/logger'; const log = createLogger('api:google-calendar'); @@ -30,8 +31,11 @@ export const GET: RequestHandler = async ({ url, locals }) => { return json({ error: 'Forbidden' }, { status: 403 }); } - if (!GOOGLE_API_KEY) { - return json({ error: 'Google API key not configured' }, { status: 500 }); + const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY; + const apiKey = env.GOOGLE_API_KEY; + + if (!serviceKey && !apiKey) { + return json({ error: 'No Google credentials configured' }, { status: 500 }); } try { @@ -53,17 +57,28 @@ export const GET: RequestHandler = async ({ url, locals }) => { log.debug('Fetching events for calendar', { data: { calendarId: orgCal.calendar_id } }); - // Fetch events for the next 3 months const now = new Date(); const timeMin = new Date(now.getFullYear(), now.getMonth() - 1, 1); const timeMax = new Date(now.getFullYear(), now.getMonth() + 3, 0); - const events = await fetchPublicCalendarEvents( - orgCal.calendar_id, - GOOGLE_API_KEY, - timeMin, - timeMax - ); + let events: unknown[]; + + // Prefer service account (works with private calendars) over public API key + if (serviceKey) { + events = await fetchCalendarEventsViaServiceAccount( + serviceKey, + orgCal.calendar_id, + timeMin, + timeMax + ); + } else { + events = await fetchPublicCalendarEvents( + orgCal.calendar_id, + apiKey!, + timeMin, + timeMax + ); + } log.debug('Fetched events', { data: { count: events.length } }); @@ -74,6 +89,6 @@ export const GET: RequestHandler = async ({ url, locals }) => { }); } catch (err) { log.error('Failed to fetch calendar events', { data: { orgId }, error: err }); - return json({ error: 'Failed to fetch events. Make sure the calendar is public.', events: [] }, { status: 500 }); + return json({ error: 'Failed to fetch events. Make sure the calendar is shared with the service account.', events: [] }, { status: 500 }); } }; diff --git a/src/routes/api/google-calendar/push/+server.ts b/src/routes/api/google-calendar/push/+server.ts new file mode 100644 index 0000000..94510f2 --- /dev/null +++ b/src/routes/api/google-calendar/push/+server.ts @@ -0,0 +1,191 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { + pushEventToGoogle, + updateGoogleEvent, + deleteGoogleEvent, + type GoogleEventPayload, +} from '$lib/api/google-calendar-push'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('api.google-calendar-push'); + +/** + * Shared auth + org membership check + */ +async function authorize(locals: App.Locals, orgId: string): Promise<{ user: { id: string }; error?: never } | { error: Response; user?: never }> { + const { session, user } = await locals.safeGetSession(); + if (!session || !user) { + return { error: json({ error: 'Unauthorized' }, { status: 401 }) }; + } + + const { data: membership } = await locals.supabase + .from('org_members') + .select('id') + .eq('org_id', orgId) + .eq('user_id', user.id) + .single(); + + if (!membership) { + return { error: json({ error: 'Forbidden' }, { status: 403 }) }; + } + + return { user }; +} + +/** + * Get the org's connected Google Calendar ID + */ +async function getOrgCalendarId(locals: App.Locals, orgId: string): Promise { + const { data } = await locals.supabase + .from('org_google_calendars') + .select('calendar_id') + .eq('org_id', orgId) + .single(); + + return data?.calendar_id ?? null; +} + +/** + * Build a Google Calendar event payload from our app's event data + */ +function buildGooglePayload(body: { + title: string; + description?: string | null; + start_time: string; + end_time: string; + all_day?: boolean; + color_id?: string; +}): GoogleEventPayload { + const base = { + summary: body.title, + description: body.description ?? undefined, + colorId: body.color_id ?? undefined, + }; + + if (body.all_day) { + const startDate = body.start_time.split('T')[0]; + const endDateObj = new Date(body.end_time.split('T')[0]); + endDateObj.setDate(endDateObj.getDate() + 1); + const endDate = endDateObj.toISOString().split('T')[0]; + + return { + ...base, + start: { date: startDate }, + end: { date: endDate }, + }; + } + + return { + ...base, + start: { dateTime: new Date(body.start_time).toISOString() }, + end: { dateTime: new Date(body.end_time).toISOString() }, + }; +} + +/** + * POST /api/google-calendar/push + * Create an event in Google Calendar and return the google_event_id + */ +export const POST: RequestHandler = async ({ request, locals }) => { + const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY; + if (!serviceKey) { + return json({ error: 'Google service account not configured' }, { status: 500 }); + } + + const body = await request.json(); + const { org_id, title, description, start_time, end_time, all_day, color_id } = body; + + if (!org_id || !title || !start_time || !end_time) { + return json({ error: 'Missing required fields' }, { status: 400 }); + } + + const auth = await authorize(locals, org_id); + if (auth.error) return auth.error; + + const calendarId = await getOrgCalendarId(locals, org_id); + if (!calendarId) { + return json({ error: 'No Google Calendar connected' }, { status: 404 }); + } + + try { + const payload = buildGooglePayload({ title, description, start_time, end_time, all_day, color_id }); + const googleEventId = await pushEventToGoogle(serviceKey, calendarId, payload); + + return json({ google_event_id: googleEventId }); + } catch (err) { + log.error('Failed to push event to Google Calendar', { error: err, data: { org_id } }); + return json({ error: 'Failed to create event in Google Calendar' }, { status: 500 }); + } +}; + +/** + * PUT /api/google-calendar/push + * Update an existing event in Google Calendar + */ +export const PUT: RequestHandler = async ({ request, locals }) => { + const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY; + if (!serviceKey) { + return json({ error: 'Google service account not configured' }, { status: 500 }); + } + + const body = await request.json(); + const { org_id, google_event_id, title, description, start_time, end_time, all_day, color_id } = body; + + if (!org_id || !google_event_id || !title || !start_time || !end_time) { + return json({ error: 'Missing required fields' }, { status: 400 }); + } + + const auth = await authorize(locals, org_id); + if (auth.error) return auth.error; + + const calendarId = await getOrgCalendarId(locals, org_id); + if (!calendarId) { + return json({ error: 'No Google Calendar connected' }, { status: 404 }); + } + + try { + const payload = buildGooglePayload({ title, description, start_time, end_time, all_day, color_id }); + await updateGoogleEvent(serviceKey, calendarId, google_event_id, payload); + + return json({ ok: true }); + } catch (err) { + log.error('Failed to update Google Calendar event', { error: err, data: { org_id, google_event_id } }); + return json({ error: 'Failed to update event in Google Calendar' }, { status: 500 }); + } +}; + +/** + * DELETE /api/google-calendar/push + * Delete an event from Google Calendar + */ +export const DELETE: RequestHandler = async ({ request, locals }) => { + const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY; + if (!serviceKey) { + return json({ error: 'Google service account not configured' }, { status: 500 }); + } + + const body = await request.json(); + const { org_id, google_event_id } = body; + + if (!org_id || !google_event_id) { + return json({ error: 'Missing required fields' }, { status: 400 }); + } + + const auth = await authorize(locals, org_id); + if (auth.error) return auth.error; + + const calendarId = await getOrgCalendarId(locals, org_id); + if (!calendarId) { + return json({ error: 'No Google Calendar connected' }, { status: 404 }); + } + + try { + await deleteGoogleEvent(serviceKey, calendarId, google_event_id); + return json({ ok: true }); + } catch (err) { + log.error('Failed to delete Google Calendar event', { error: err, data: { org_id, google_event_id } }); + return json({ error: 'Failed to delete event from Google Calendar' }, { status: 500 }); + } +}; diff --git a/src/routes/api/release-lock/+server.ts b/src/routes/api/release-lock/+server.ts new file mode 100644 index 0000000..6afab9d --- /dev/null +++ b/src/routes/api/release-lock/+server.ts @@ -0,0 +1,41 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('api.release-lock'); + +/** + * POST /api/release-lock + * Called via navigator.sendBeacon() when the user navigates away from a document. + * Releases the document lock so other users can edit immediately. + */ +export const POST: RequestHandler = async ({ request, locals }) => { + const { session, user } = await locals.safeGetSession(); + if (!session || !user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const { documentId } = await request.json(); + if (!documentId) { + return json({ error: 'Missing documentId' }, { status: 400 }); + } + + // Only allow releasing your own lock + const { error } = await locals.supabase + .from('document_locks') + .delete() + .eq('document_id', documentId) + .eq('user_id', user.id); + + if (error) { + log.error('Failed to release lock', { error, data: { documentId, userId: user.id } }); + return json({ error: 'Failed to release lock' }, { status: 500 }); + } + + return json({ ok: true }); + } catch (e) { + log.error('release-lock request failed', { error: e }); + return json({ error: 'Invalid request' }, { status: 400 }); + } +}; diff --git a/src/routes/auth/callback/+server.ts b/src/routes/auth/callback/+server.ts index 1699d2d..b64eab2 100644 --- a/src/routes/auth/callback/+server.ts +++ b/src/routes/auth/callback/+server.ts @@ -13,6 +13,21 @@ export const GET: RequestHandler = async ({ url, locals }) => { if (code) { const { error } = await locals.supabase.auth.exchangeCodeForSession(code); if (!error) { + // Sync avatar from OAuth provider metadata into profiles + const { data: { user } } = await locals.supabase.auth.getUser(); + if (user) { + const avatarUrl = user.user_metadata?.avatar_url || user.user_metadata?.picture || null; + const fullName = user.user_metadata?.full_name || user.user_metadata?.name || null; + if (avatarUrl || fullName) { + const updates: Record = {}; + if (avatarUrl) updates.avatar_url = avatarUrl; + if (fullName) updates.full_name = fullName; + await locals.supabase + .from('profiles') + .update(updates) + .eq('id', user.id); + } + } redirect(303, next); } } diff --git a/src/routes/invite/[token]/+page.server.ts b/src/routes/invite/[token]/+page.server.ts index ad9a64a..1172056 100644 --- a/src/routes/invite/[token]/+page.server.ts +++ b/src/routes/invite/[token]/+page.server.ts @@ -32,12 +32,14 @@ export const load: PageServerLoad = async ({ params, locals }) => { // Get current user const { data: { user } } = await locals.supabase.auth.getUser(); + const org = (invite as Record).organizations as { id: string; name: string; slug: string } | null; + return { invite: { id: invite.id, email: invite.email, role: invite.role, - org: (invite as any).organizations // join not typed + org, }, user, token diff --git a/src/routes/invite/[token]/+page.svelte b/src/routes/invite/[token]/+page.svelte index 6cd1cd8..a1e2863 100644 --- a/src/routes/invite/[token]/+page.svelte +++ b/src/routes/invite/[token]/+page.svelte @@ -1,6 +1,7 @@