diff --git a/.gitignore b/.gitignore index 8ee777b..55bedb1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ Desktop.ini # IDE / Editors .idea -.vscode/* +.vscode !.vscode/extensions.json !.vscode/settings.json *.swp diff --git a/AUDIT.md b/AUDIT.md deleted file mode 100644 index 316210d..0000000 --- a/AUDIT.md +++ /dev/null @@ -1,465 +0,0 @@ -# Comprehensive Codebase Audit Report (v5) - -**Project:** root-org โ€” Event Organizing Platform (SvelteKit 2 + Svelte 5 + Supabase + Tailwind v4) -**Date:** 2026-02-07 -**Auditor:** Cascade - ---- - -## Baseline Status - -| Metric | Result | -|--------|--------| -| `svelte-check` | **0 errors**, 9 warnings (a11y) | -| `vite build` | **Success** | -| `vitest` | **112 tests passed** across 9 files | -| TypeScript | `strict: true` enabled | -| Migrations | 31 SQL files (001โ€“031) | -| Components | 44 UI + 8 module widgets + 15 matrix + 11 message | -| API modules | 13 files in `$lib/api/` | -| Routes | 7 route groups + 5 API endpoints | - ---- - -## 1. Security - -### S-1 ยท ๐Ÿ”ด CRITICAL โ€” Real credentials committed to `.env` in git history - -**File:** `.env` - -The `.env` file contains real Supabase keys, Google API key, Google service account private key, and Matrix admin token. While `.env` is in `.gitignore`, **it was committed before the rule was added** and remains in git history. - -**Exposed secrets:** -- Supabase URL + anon key -- Google API key -- Google service account full JSON (including private key) -- Matrix admin token - -**Fix (manual โ€” cannot be automated):** -1. `git rm --cached .env` to untrack -2. Use `git filter-repo` to purge `.env` from all history -3. **Rotate ALL keys immediately** โ€” Supabase anon key, Google API key, Google service account, Matrix admin token -4. Force-push the cleaned history - -**Severity:** If repo is ever made public or shared, all backend services are fully compromised. - ---- - -### S-2 ยท ๐Ÿ”ด CRITICAL โ€” Google Calendar events endpoint lacks auth - -**File:** `src/routes/api/google-calendar/events/+server.ts` - -The `GET` handler reads `org_id` from query params and fetches calendar events. It does check auth (`safeGetSession`) and membership โ€” **this was fixed in a previous audit round**. However, verify the current state matches the fix. - -**Status:** Verify โ€” was marked fixed in v4 audit. - ---- - -### S-3 ยท ๐ŸŸก HIGH โ€” Auth callback open redirect - -**File:** `src/routes/auth/callback/+server.ts` - -The `next`/`redirect` query parameter is used in `redirect(303, next)` without validating it's a relative URL. Attacker can craft `?next=https://evil.com`. - -**Fix:** -```ts -function safeRedirect(target: string): string { - if (target.startsWith('/') && !target.startsWith('//')) return target; - return '/'; -} -``` - ---- - -### S-4 ยท ๐ŸŸก HIGH โ€” Client-side mutations bypass server authorization - -**Problem:** Settings page, event pages, and team management perform Supabase mutations directly from the browser. Security relies entirely on RLS policies being correct. The admin dashboard (newly added) correctly uses SvelteKit form actions โ€” this pattern should be adopted everywhere. - -**Affected areas:** -- `[orgSlug]/settings/` โ€” org update, delete, member management -- `[orgSlug]/events/[eventSlug]/` โ€” event update, delete -- `[orgSlug]/events/[eventSlug]/team/` โ€” member add/remove, department CRUD - -**Fix:** Migrate destructive operations to SvelteKit form actions with explicit server-side auth checks (like the admin dashboard pattern). - ---- - ---- - -### S-6 ยท ๐ŸŸก MEDIUM โ€” Document lock RLS race condition - -**File:** `supabase/migrations/016_document_locks.sql` - -Two competing cleanup paths for expired locks: RLS policy allows any user to delete expired locks, and `acquireLock()` in `document-locks.ts` also deletes them client-side. - -**Fix:** Consolidate to one path โ€” either RLS-only or a server-side cron. - ---- - -### S-7 ยท ๐ŸŸก MEDIUM โ€” `@inlang/paraglide-js` in both deps and devDeps - -**File:** `package.json` - -`@inlang/paraglide-js` appears in both `dependencies` and `devDependencies`. Should only be in `devDependencies` (it's a build-time tool). - ---- - -## 2. Type Safety - -### T-1 ยท ๐ŸŸก HIGH โ€” Supabase types are stale โ€” 21 `as any` casts remain - -**Files:** 12 files still use `as any`, concentrated in: -- `src/lib/matrix/sdk-types.ts` (6) โ€” Matrix SDK interop, acceptable -- `src/lib/api/budget.ts`, `contacts.ts`, `schedule.ts`, `sponsors.ts`, `department-dashboard.ts` (5 total) โ€” all use `db()` cast for tables added in migrations 026-031 -- `src/routes/admin/+page.server.ts` (1) โ€” admin uses `db()` for `is_platform_admin` -- `src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.server.ts` (3) -- `src/routes/[orgSlug]/events/[eventSlug]/team/+page.svelte` (1) -- `src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.svelte` (1) -- `src/lib/utils/permissions.ts` (1) -- Test files (3) โ€” acceptable in tests - -**Root cause:** Types haven't been regenerated since migrations 022-031 were added. Tables like `events`, `event_members`, `event_departments`, `department_dashboards`, `budget_categories`, `sponsors`, `schedule_stages`, `department_contacts`, and column `is_platform_admin` on `profiles` are all missing from the generated types. - -**Fix:** Run `npm run db:types` to regenerate. This single command will eliminate ~15 of the 21 `as any` casts. The remaining 6 (Matrix SDK) are acceptable interop casts. - ---- - -### T-2 ยท ๐ŸŸก MEDIUM โ€” Manual type aliases drift from generated types - -**File:** `src/lib/supabase/types.ts:1700-1877` - -15 manually-defined interfaces at the bottom of the generated types file (`DepartmentDashboard`, `BudgetCategory`, `Sponsor`, etc.). These will become redundant after type regeneration and could drift from the actual DB schema. - -**Fix:** After regenerating types, derive these from the generated `Database` type: -```ts -export type BudgetCategory = Database['public']['Tables']['budget_categories']['Row']; -``` - ---- - -### T-3 ยท ๐ŸŸข GOOD โ€” TypeScript strict mode enabled - -`tsconfig.json` has `"strict": true`, `"checkJs": true`, `"forceConsistentCasingInFileNames": true`. This is best-practice configuration. - ---- - -## 3. Code Quality & Consistency - -### C-1 ยท ๐ŸŸก HIGH โ€” 41 raw `console.*` calls bypass structured logger - -**13 files** use `console.log/warn/error` directly instead of the project's `createLogger()` system. Concentrated in: -- `src/lib/matrix/client.ts` (10) -- `src/lib/components/matrix/MessageInput.svelte` (5) -- `src/routes/[orgSlug]/chat/+page.svelte` (5) -- `src/routes/api/matrix-provision/+server.ts` (5) -- Various matrix components (6) -- `src/lib/stores/theme.ts` (1) - -**Impact:** Loses structured context, timestamps, and the ring buffer for error reports. The logger is already imported in most non-matrix files. - -**Fix:** Replace all `console.*` with `createLogger()` calls. The Matrix integration was ported from another project and didn't adopt the logger. - ---- - -### C-2 ยท ๐ŸŸก MEDIUM โ€” Inconsistent error catching: `e: any` vs `e` vs `e: unknown` - -The codebase uses three different patterns: -- `catch (e: any)` โ€” 12 occurrences (team page, events pages, dept page) -- `catch (e)` โ€” 8 occurrences (tasks page, documents page, chat page) -- `catch (e: unknown)` โ€” 1 occurrence (chat page) - -**Best practice:** Use `catch (e: unknown)` and narrow with `e instanceof Error`. The `e: any` pattern loses type safety. - ---- - -### C-3 ยท ๐ŸŸก MEDIUM โ€” `$lib/stores/ui.ts` uses legacy Svelte stores alongside Svelte 5 runes - -**File:** `src/lib/stores/ui.ts` - -Uses `writable()` from `svelte/store` for `sidebarOpen`, `membersPanelOpen`, `activeModal`, `modalData`, `isLoading`, `loadingMessage`. The rest of the app uses Svelte 5 `$state()` runes. - -**Fix:** Migrate to runes-based stores using `.svelte.ts` files (like `toast.svelte.ts` already does). - ---- - -### C-4 ยท ๐ŸŸก MEDIUM โ€” 9 a11y warnings from svelte-check - -All in 3 files: -- `SponsorsWidget.svelte` โ€” color picker buttons missing labels (4 warnings) -- `admin/+page.svelte` โ€” form labels not associated with controls (3 warnings) -- `SponsorsWidget.svelte` โ€” buttons without text (2 warnings) - -**Fix:** Add `aria-label` attributes to color picker buttons; use `id`/`for` pairs on admin form labels. - ---- - -### C-5 ยท ๐ŸŸข GOOD โ€” Consistent API module pattern - -All 13 API modules in `$lib/api/` follow the same pattern: typed function signatures, Supabase client as first param, structured error logging via `createLogger()`, and thrown errors for callers to handle. This is clean and maintainable. - ---- - -### C-6 ยท ๐ŸŸข GOOD โ€” Centralized error handling - -Both server (`hooks.server.ts`) and client (`hooks.client.ts`) have `handleError` functions that generate error IDs, log structured data, and return consistent error shapes. The `+error.svelte` page shows error ID + "Copy Error Report" with recent logs dump. This is production-quality. - ---- - -## 4. Architecture - -### A-1 ยท ๐ŸŸก HIGH โ€” Client-side mutations should use form actions - -The admin dashboard correctly uses SvelteKit form actions for all CRUD. But other pages (settings, events, team management) perform mutations via direct `supabase.from()` calls from the browser. - -**Recommendation:** Adopt the admin dashboard pattern (form actions + `use:enhance`) for all destructive operations. This provides: -- Server-side auth verification -- Progressive enhancement (works without JS) -- Automatic `invalidateAll()` on success - ---- - -### A-2 ยท ๐ŸŸก MEDIUM โ€” Large page components - -Several pages are 500+ lines: -- `[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.svelte` โ€” ~1100 lines (department dashboard) -- `[orgSlug]/events/[eventSlug]/team/+page.svelte` โ€” ~500 lines -- `[orgSlug]/chat/+page.svelte` โ€” ~400 lines -- `admin/+page.svelte` โ€” ~660 lines - -The department dashboard is the largest but is inherently complex (8 module types). The admin page is well-structured with tab separation. - -**Recommendation:** Extract reusable table/list components from admin page for potential reuse in other admin-like views. - ---- - -### A-3 ยท ๐ŸŸข GOOD โ€” Clean module architecture - -The project follows a clear separation: -- `$lib/api/` โ€” Data access layer (13 modules) -- `$lib/components/ui/` โ€” Design system (44 components) -- `$lib/components/modules/` โ€” Feature widgets (8 modules) -- `$lib/stores/` โ€” Global state (4 stores) -- `$lib/utils/` โ€” Utilities (7 files) -- `$lib/matrix/` โ€” Matrix chat integration (isolated) - ---- - -### A-4 ยท ๐ŸŸข GOOD โ€” Server-side auth pattern - -`hooks.server.ts` correctly uses `getUser()` (server-verified) before `getSession()` (client-provided). The `safeGetSession` pattern prevents session spoofing. All page server loads check auth before data fetching. - ---- - -## 5. Performance - -### P-1 ยท ๐ŸŸก MEDIUM โ€” Google Fonts loaded via external CSS import - -**File:** `src/routes/layout.css:1-2` - -```css -@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&...'); -@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:...'); -``` - -Two render-blocking external CSS imports. Material Symbols font is ~200KB. - -**Fix options:** -1. Self-host fonts (fastest โ€” eliminates external dependency) -2. Add `` to `app.html` -3. Use `font-display: swap` (already set via `&display=swap`) - ---- - -### P-2 ยท ๐ŸŸก MEDIUM โ€” `emojiData.ts` is 40KB - -**File:** `src/lib/utils/emojiData.ts` (40,133 bytes) - -This is a static JSON blob imported at module level. It's included in the client bundle even if the user never opens the emoji picker. - -**Fix:** Lazy-load via dynamic import: -```ts -const emojiData = await import('$lib/utils/emojiData'); -``` - ---- - -### P-3 ยท ๐ŸŸข GOOD โ€” Optimistic updates on kanban - -Card moves use synchronous state updates + fire-and-forget DB persist. Realtime handlers use incremental diffs instead of full reloads. `optimisticMoveIds` Set suppresses realtime echoes. This is well-implemented. - ---- - -### P-4 ยท ๐ŸŸข GOOD โ€” Vite config optimized for Windows - -File watcher ignores `node_modules`, `test-results`, `.svelte-kit`, and paraglide cache. Matrix crypto WASM excluded from SSR bundling. This prevents common Windows dev server performance issues. - ---- - -## 6. Testing - -### TS-1 ยท ๐ŸŸก MEDIUM โ€” No Svelte component tests - -112 unit tests exist but all test pure TypeScript functions (API modules, utils, markdown parsing). No Svelte component rendering tests despite `vitest-browser-svelte` being installed and configured. - -**Priority components to test:** -- `Button`, `Modal`, `Input`, `TabBar` โ€” core UI -- `Avatar` โ€” name-to-color hashing logic -- `StatusBadge` โ€” status-to-color mapping - ---- - -### TS-2 ยท ๐ŸŸก MEDIUM โ€” E2E tests don't cover new features - -Playwright tests cover: auth, org CRUD, documents, kanban, calendar. But missing: -- Events CRUD flow -- Department dashboard -- Admin dashboard -- Chat integration -- Team management - ---- - -### TS-3 ยท ๐ŸŸข GOOD โ€” Test infrastructure is solid - -- Vitest with browser + server projects -- `requireAssertions: true` prevents empty tests -- Playwright with auth setup, cleanup, and CI config -- 112 passing tests with 0 failures - ---- - -## 7. Dependencies - -### DEP-1 ยท ๐ŸŸก MEDIUM โ€” `@inlang/paraglide-js` duplicated in deps - -Listed in both `dependencies` and `devDependencies`. Should only be in `devDependencies`. - ---- - -### DEP-2 ยท ๐ŸŸก LOW โ€” `@types/twemoji` may be unnecessary - -`twemoji` v14 may ship its own types. Check if removing `@types/twemoji` causes any errors. - ---- - -### DEP-3 ยท ๐ŸŸข GOOD โ€” All dependencies actively used - -Every package in `dependencies` has corresponding imports: -- `@supabase/ssr` + `@supabase/supabase-js` โ€” core data layer -- `@tanstack/svelte-virtual` โ€” virtualized lists in chat -- `@tiptap/*` โ€” rich text editor -- `google-auth-library` โ€” calendar integration -- `highlight.js` + `marked` โ€” markdown rendering -- `matrix-js-sdk` โ€” chat -- `twemoji` โ€” emoji rendering - ---- - -## 8. DevOps & Infrastructure - -### DO-1 ยท ๐ŸŸข GOOD โ€” Docker setup - -Multi-stage Dockerfile with builder + production stages. Health check endpoint at `/health`. `docker-compose.yml` with both production and dev services. Environment variables properly externalized. - ---- - -### DO-2 ยท ๐ŸŸข GOOD โ€” Database migrations - -31 sequential migrations with clear naming. RLS policies on all tables. Platform admin bypass policies (migration 031). Auto-create triggers for department dashboards. - ---- - -### DO-3 ยท ๐ŸŸก MEDIUM โ€” No database seed file - -No `supabase/seed.sql` for development setup. New developers must manually create test data. - -**Fix:** Create a seed file with sample org, users, events, and departments. - ---- - -## 9. Accessibility - -### A11Y-1 ยท ๐ŸŸก MEDIUM โ€” 9 svelte-check a11y warnings - -- Color picker buttons in `SponsorsWidget.svelte` lack labels -- Admin page form labels not associated with controls -- All are fixable with `aria-label` or `id`/`for` attributes - ---- - -### A11Y-2 ยท ๐ŸŸข GOOD โ€” Modal accessibility - -`Modal.svelte` has `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, keyboard escape handling, and backdrop click. This follows WAI-ARIA dialog pattern. - ---- - -### A11Y-3 ยท ๐ŸŸข GOOD โ€” Error page - -`+error.svelte` shows status code, message, error ID, context, and a "Copy Error Report" button with recent logs. Excellent for debugging. - ---- - ---- - -## Summary Scorecard (v5) - -| Area | Score | Key Notes | -|------|-------|-----------| -| **Security** | 3/5 | S-1 (credential rotation) still critical. S-4 (server-side auth) partially addressed by admin form actions. RLS bypass for platform admins added. | -| **Type Safety** | 4/5 | `strict: true`. 21 `as any` casts remain, 15 fixable by `npm run db:types`. Matrix SDK casts are acceptable. | -| **Code Quality** | 4/5 | Consistent API pattern. 41 raw `console.*` calls in Matrix code. Inconsistent error catch typing. | -| **Architecture** | 4/5 | Clean module separation. Admin dashboard uses form actions (good pattern). Other pages still use client-side mutations. | -| **Performance** | 4/5 | Optimistic updates, incremental realtime, Windows-optimized watcher. External font loading and 40KB emoji data could improve. | -| **Testing** | 3.5/5 | 112 unit tests, Playwright E2E, CI pipeline. Missing: component tests, new feature E2E coverage. | -| **Dependencies** | 4.5/5 | All deps used. One duplicate (`paraglide-js`). | -| **DevOps** | 4/5 | Docker, health checks, 31 migrations. Missing: seed file. | -| **Accessibility** | 4/5 | Good modal/error patterns. 9 minor a11y warnings. | -| **Error Handling** | 4/5 | Structured logger, error IDs, ring buffer, toast system. Matrix code bypasses logger. | - -### Overall: 4.0 / 5.0 - ---- - -## Priority Action Items - -### Tier 1 โ€” Critical (do now) - -1. **S-1: Purge `.env` from git history and rotate all secrets** - - `git rm --cached .env && git commit` - - Use `git filter-repo` to purge from history - - Rotate: Supabase keys, Google API key, Google service account, Matrix admin token - -### Tier 2 โ€” High (this week) - -2. **T-1: Regenerate Supabase types** โ€” `npm run db:types` eliminates 15 `as any` casts -3. **C-1: Replace `console.*` with `createLogger()`** in Matrix integration files -4. **S-3: Fix auth callback open redirect** โ€” add `safeRedirect()` validator -5. **A11Y-1: Fix 9 a11y warnings** โ€” add `aria-label` and `id`/`for` attributes - -### Tier 3 โ€” Medium (this month) - -6. **S-4/A-1: Migrate mutations to form actions** โ€” start with settings page -7. **C-3: Migrate `$lib/stores/ui.ts` to runes** โ€” align with Svelte 5 patterns -8. **P-1: Self-host fonts** or add preconnect hints -9. **P-2: Lazy-load emoji data** -10. **TS-1: Add Svelte component tests** for core UI components -11. **DO-3: Create database seed file** -12. **DEP-1: Remove duplicate `paraglide-js`** from `dependencies` - -### Tier 4 โ€” Low (backlog) - -13. **C-2: Standardize error catching** to `catch (e: unknown)` -14. **TS-2: Expand E2E tests** to cover events, departments, admin, chat -15. **T-2: Derive manual type aliases** from generated types after regeneration -16. **S-6: Consolidate lock cleanup** to single path -17. **DEP-2: Check if `@types/twemoji` is needed** - -### Feature Backlog - -18. Permission enforcement (`hasPermission()` utility) -19. Global search across all entities -20. Keyboard shortcuts -21. Notifications system -22. Mobile responsive layout -23. Undo/redo with toast-based undo -24. Onboarding flow for new users diff --git a/docs/TECHNICAL_DESIGN.md b/docs/TECHNICAL_DESIGN.md new file mode 100644 index 0000000..9e78f29 --- /dev/null +++ b/docs/TECHNICAL_DESIGN.md @@ -0,0 +1,997 @@ +# Root โ€” Technical Design Document + +> Comprehensive documentation of every feature, how it works, what problems it solves, and where there is room for improvement. + +--- + +## Table of Contents + +1. [Platform Overview](#1-platform-overview) +2. [Architecture](#2-architecture) +3. [Authentication & Authorization](#3-authentication--authorization) +4. [Organizations](#4-organizations) +5. [Organization Settings](#5-organization-settings) +6. [Documents & File Browser](#6-documents--file-browser) +7. [Kanban Boards](#7-kanban-boards) +8. [Calendar](#8-calendar) +9. [Chat (Matrix)](#9-chat-matrix) +10. [Events](#10-events) +11. [Event Team & Departments](#11-event-team--departments) +12. [Department Dashboard](#12-department-dashboard) +13. [Checklists Module](#13-checklists-module) +14. [Notes Module](#14-notes-module) +15. [Schedule Module](#15-schedule-module) +16. [Contacts Module](#16-contacts-module) +17. [Budget & Finances Module](#17-budget--finances-module) +18. [Sponsors Module](#18-sponsors-module) +19. [Activity Tracking](#19-activity-tracking) +20. [Tags System](#20-tags-system) +21. [Roles & Permissions](#21-roles--permissions) +22. [Google Calendar Integration](#22-google-calendar-integration) +23. [Internationalization (i18n)](#23-internationalization-i18n) +24. [Platform Admin](#24-platform-admin) +25. [Testing Strategy](#25-testing-strategy) +26. [Database Migrations](#26-database-migrations) + +--- + +## 1. Platform Overview + +### What It Is +Root is an **event organizing platform** built for teams and organizations that plan events. It provides a unified workspace where teams can manage documents, tasks, budgets, sponsors, schedules, contacts, and communications โ€” all scoped to specific events and departments. + +### Problems It Solves +- **Fragmented tooling**: Event teams typically juggle Google Sheets, Slack, Trello, email, and spreadsheets. Root consolidates these into one platform. +- **No event-scoped context**: Generic project tools don't understand the concept of "events with departments." Root's data model is purpose-built for this. +- **Budget opacity**: Planned vs. actual spending is hard to track across departments. Root provides real-time financial rollups. +- **Sponsor management**: No lightweight CRM exists for event sponsorships. Root provides tier-based sponsor tracking with deliverables. + +### Tech Stack +| Layer | Technology | +|-------|-----------| +| Frontend | SvelteKit (Svelte 5), TailwindCSS | +| Backend | Supabase (PostgreSQL, Auth, Storage, Realtime) | +| Chat | Matrix protocol (Synapse server) | +| Calendar | Google Calendar API (OAuth2) | +| i18n | Paraglide v2 (en, et) | +| Icons | Material Symbols (variable font) | +| Testing | Vitest (unit), Playwright (E2E) | +| Deployment | Vercel / Netlify | + +--- + +## 2. Architecture + +### Routing Structure +``` +/ โ†’ Landing / login +/[orgSlug]/ โ†’ Organization overview +/[orgSlug]/documents/ โ†’ File browser (root) +/[orgSlug]/documents/folder/[id] โ†’ Folder view +/[orgSlug]/documents/file/[id] โ†’ Document / kanban viewer +/[orgSlug]/calendar/ โ†’ Calendar page +/[orgSlug]/chat/ โ†’ Matrix chat +/[orgSlug]/events/ โ†’ Event list +/[orgSlug]/events/[eventSlug]/ โ†’ Event detail +/[orgSlug]/events/[eventSlug]/team/ โ†’ Team management +/[orgSlug]/events/[eventSlug]/finances/ โ†’ Financial overview +/[orgSlug]/events/[eventSlug]/sponsors/ โ†’ Sponsor CRM +/[orgSlug]/events/[eventSlug]/contacts/ โ†’ Contact directory +/[orgSlug]/events/[eventSlug]/schedule/ โ†’ Schedule builder +/[orgSlug]/events/[eventSlug]/dept/[deptId]/ โ†’ Department dashboard +/[orgSlug]/settings/ โ†’ Organization settings +/[orgSlug]/account/ โ†’ User account settings +/admin/ โ†’ Platform admin panel +``` + +### Data Flow +1. **Layout server** (`[orgSlug]/+layout.server.ts`) loads org data, membership, permissions, members, activity, stats, and upcoming events via `select('*')` on the `organizations` table. +2. **Child pages** call `parent()` to inherit org context, then load page-specific data. +3. **Supabase client** is set via `setContext('supabase')` in the root layout and retrieved via `getContext` in child components. +4. **Realtime** subscriptions use Supabase channels for live updates (kanban cards, chat presence). + +### Key Files +| File | Purpose | +|------|---------| +| `src/routes/[orgSlug]/+layout.server.ts` | Loads org, membership, permissions, members, stats | +| `src/routes/[orgSlug]/+layout.svelte` | Sidebar navigation, user menu, org header | +| `src/lib/supabase/types.ts` | Auto-generated + manual TypeScript types | +| `src/lib/utils/currency.ts` | Shared currency formatting, timezone, date format constants | +| `src/lib/utils/permissions.ts` | Permission checking utility | +| `src/lib/stores/toast.svelte` | Toast notification store | +| `src/lib/paraglide/messages.ts` | Generated i18n message functions | + +--- + +## 3. Authentication & Authorization + +### How It Works +- **Supabase Auth** handles email/password login with session management. +- Sessions are persisted via cookies; `locals.safeGetSession()` validates on every server load. +- **Playwright tests** use a setup project that logs in once and saves `storageState` for reuse. + +### Authorization Layers +1. **Org membership**: Users must be members of an org to access it (checked in layout server). +2. **Role-based**: Each member has a role (`owner`, `admin`, `editor`, `viewer`). +3. **Permission-based**: Custom roles with granular permissions (e.g., `documents.view`, `calendar.view`, `settings.view`). +4. **Row-Level Security (RLS)**: All Supabase tables have RLS policies scoped to org membership. + +### Room for Improvement +- **Invite flow**: Currently email-based; could add link-based invites. +- **SSO**: No SAML/OIDC support yet. +- **2FA**: Not implemented. + +--- + +## 4. Organizations + +### What It Is +The top-level entity. Every user belongs to one or more organizations. All data (events, documents, members) is scoped to an org. + +### Database Schema +```sql +CREATE TABLE organizations ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + avatar_url TEXT, + theme_color TEXT DEFAULT '#00a3e0', + icon_url TEXT, + description TEXT DEFAULT '', + currency TEXT NOT NULL DEFAULT 'EUR', + date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY', + timezone TEXT NOT NULL DEFAULT 'Europe/Tallinn', + week_start_day TEXT NOT NULL DEFAULT 'monday', + default_calendar_view TEXT NOT NULL DEFAULT 'month', + default_event_color TEXT NOT NULL DEFAULT '#7986cb', + default_event_status TEXT NOT NULL DEFAULT 'planning', + default_dept_modules TEXT[] NOT NULL DEFAULT ARRAY['kanban','files','checklist'], + default_dept_layout TEXT NOT NULL DEFAULT 'split', + feature_chat BOOLEAN NOT NULL DEFAULT true, + feature_sponsors BOOLEAN NOT NULL DEFAULT true, + feature_contacts BOOLEAN NOT NULL DEFAULT true, + feature_budget BOOLEAN NOT NULL DEFAULT true, + matrix_space_id TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); +``` + +### API (`src/lib/api/organizations.ts`) +- `createOrganization(supabase, name, slug, userId)` โ€” creates org + adds creator as owner +- `updateOrganization(supabase, id, updates)` โ€” updates name, slug, avatar +- `deleteOrganization(supabase, id)` โ€” deletes org and cascades +- `fetchOrgMembers(supabase, orgId)` โ€” lists members with profiles +- `inviteMember(supabase, orgId, email, role)` โ€” sends invite +- `updateMemberRole(supabase, memberId, role)` โ€” changes role +- `removeMember(supabase, memberId)` โ€” removes member + +### Overview Page (`/[orgSlug]/`) +Displays: +- **Stat cards**: Events, members, documents, kanban boards +- **Upcoming events**: Next 5 events with status badges +- **Recent activity**: Last 10 activity log entries +- **Member list**: First 6 members with avatars + +--- + +## 5. Organization Settings + +### What It Is +Configurable preferences that affect the entire organization. Accessible at `/[orgSlug]/settings` under the "General" tab. + +### Sections + +#### Organization Details +- **Name** and **URL slug** โ€” core identity +- **Avatar** โ€” upload/remove via Supabase Storage +- **Description** โ€” free-text org description +- **Theme color** โ€” color picker, stored as hex + +#### Preferences +- **Currency** โ€” ISO 4217 code (EUR, USD, GBP, etc.). Affects all financial displays via `formatCurrency()` utility. Uses locale-aware formatting (EUR โ†’ `de-DE` locale puts โ‚ฌ after number). +- **Date format** โ€” DD/MM/YYYY, MM/DD/YYYY, or YYYY-MM-DD +- **Timezone** โ€” grouped by region (Europe, Americas, Asia/Pacific) +- **Week start day** โ€” Monday or Sunday +- **Default calendar view** โ€” Month, Week, or Day + +#### Event Defaults +- **Default event color** โ€” preset palette + custom color picker +- **Default event status** โ€” Planning or Active +- **Default department layout** โ€” Single, Split, Grid, or Focus+Sidebar +- **Default department modules** โ€” toggle grid for: Kanban, Files, Checklist, Notes, Schedule, Contacts, Budget, Sponsors + +#### Feature Toggles +- **Chat** โ€” show/hide Matrix chat in sidebar +- **Budget & Finances** โ€” enable/disable budget module +- **Sponsors** โ€” enable/disable sponsor CRM +- **Contacts** โ€” enable/disable contact directory + +#### Danger Zone +- Delete organization (owner only) +- Leave organization (non-owners) + +### Implementation +- **Settings UI**: `src/lib/components/settings/SettingsGeneral.svelte` +- **Currency utility**: `src/lib/utils/currency.ts` โ€” shared `formatCurrency(amount, currency)` function with locale map +- **Feature toggles**: Wired into sidebar nav in `[orgSlug]/+layout.svelte` โ€” conditionally shows/hides nav items +- **Calendar default**: Passed as `initialView` prop to `Calendar.svelte` +- **Event color default**: Used in event creation form (`events/+page.svelte`) + +### Room for Improvement +- **Date format** is stored but not yet consumed by all date displays (calendar, activity feed, event cards). Need a shared `formatDate()` utility. +- **Timezone** is stored but not yet used for server-side date rendering. +- **Week start day** is stored but Calendar grid is still hardcoded to Monday. +- **Feature toggles** hide sidebar nav but don't yet block API access or hide modules within department dashboards. +- **Default dept modules/layout** are stored but not yet consumed by the department auto-create trigger. + +--- + +## 6. Documents & File Browser + +### What It Is +A hierarchical file system with folders, rich-text documents, and kanban boards. Supports org-wide and event-scoped views. + +### Problems It Solves +- Centralized document storage per organization +- Auto-created folder structure for events and departments +- In-browser document editing without external tools + +### Database +```sql +CREATE TABLE documents ( + id UUID PRIMARY KEY, + org_id UUID REFERENCES organizations(id), + parent_id UUID REFERENCES documents(id), -- folder hierarchy + type TEXT NOT NULL, -- 'folder' | 'document' | 'kanban' + title TEXT NOT NULL, + content JSONB, -- TipTap JSON for documents + position INTEGER, + event_id UUID REFERENCES events(id), -- event scoping + department_id UUID REFERENCES event_departments(id), -- dept scoping + created_by UUID, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ +); +``` + +### Routing +- `/documents` โ€” root file browser +- `/documents/folder/[id]` โ€” folder contents +- `/documents/file/[id]` โ€” document editor or kanban board + +### Auto-Folder System +When events/departments are created, folders are auto-created: +``` +๐Ÿ“ Events/ + ๐Ÿ“ [Event Name]/ (event_id FK) + ๐Ÿ“ [Department Name]/ (department_id FK) +``` + +### Key Features +- **Drag-and-drop** file/folder reordering +- **Breadcrumb** navigation +- **Document locking** โ€” prevents concurrent edits +- **Single-click** folder โ†’ navigate; document โ†’ inline editor; kanban โ†’ full page +- **Double-click** โ†’ open in new tab + +### API (`src/lib/api/documents.ts`) +- `fetchDocuments`, `createDocument`, `moveDocument`, `deleteDocument` +- `ensureEventsFolder`, `createEventFolder`, `createDepartmentFolder` +- `findDepartmentFolder`, `fetchFolderContents` + +### Room for Improvement +- **Real-time collaboration**: Currently single-user editing with locks. Could add CRDT-based collaborative editing. +- **Search**: No full-text search across documents. +- **Version history**: No document versioning. +- **File uploads**: Only structured documents; no raw file upload (PDF, images). + +--- + +## 7. Kanban Boards + +### What It Is +Drag-and-drop task boards with columns, cards, labels, checklists, and comments. + +### Problems It Solves +- Visual task management within the document system +- Scoped task tracking per department + +### Database +```sql +CREATE TABLE kanban_columns (id, document_id, title, position, color); +CREATE TABLE kanban_cards (id, column_id, title, description, position, due_date, assigned_to); +CREATE TABLE kanban_labels (id, document_id, name, color); +CREATE TABLE card_checklists (id, card_id, title); +CREATE TABLE card_checklist_items (id, checklist_id, text, completed, position); +CREATE TABLE task_comments (id, card_id, user_id, content, created_at); +``` + +### Key Features +- **Optimistic drag-and-drop**: Cards move instantly; DB persists fire-and-forget +- **Realtime**: Incremental handlers for INSERT/UPDATE/DELETE via Supabase channels +- **Labels**: Color-coded tags per board +- **Card detail**: Description, due date, assignee, checklists, comments +- **Drop indicators**: CSS box-shadow based (no layout shift) + +### API (`src/lib/api/kanban.ts`) +- `fetchBoard`, `createColumn`, `moveColumn`, `createCard`, `moveCard`, `updateCard` + +### Room for Improvement +- **Swimlanes**: No horizontal grouping by assignee or label. +- **Filters**: No filtering by label, assignee, or due date on the board view. +- **Card templates**: No reusable card templates. + +--- + +## 8. Calendar + +### What It Is +A month/week/day calendar view showing org events and optionally synced Google Calendar events. + +### Problems It Solves +- Unified view of all organization events +- Google Calendar sync for external event visibility + +### Implementation +- **Component**: `src/lib/components/calendar/Calendar.svelte` +- **Views**: Month (grid), Week (7-column), Day (single column with time slots) +- **Props**: `events`, `onDateClick`, `onEventClick`, `initialView` +- **Default view**: Configurable per org via `default_calendar_view` setting + +### API (`src/lib/api/calendar.ts`) +- `getMonthDays(year, month)` โ€” generates date grid for month view +- `isSameDay(date1, date2)` โ€” date comparison utility + +### Room for Improvement +- **Week start day**: Setting exists but calendar grid is hardcoded to Monday start. +- **Timezone**: Events display in browser timezone; should respect org timezone setting. +- **Recurring events**: Not supported. +- **Drag to create**: Can't drag across time slots to create events. + +--- + +## 9. Chat (Matrix) + +### What It Is +Real-time messaging powered by the Matrix protocol, using a self-hosted Synapse server. + +### Problems It Solves +- Team communication without leaving the platform +- Org-scoped chat rooms (no cross-org leakage) + +### Implementation +- Each org gets a Matrix space (`matrix_space_id` on organizations table) +- Users get Matrix credentials stored in `matrix_credentials` table +- Chat UI at `/[orgSlug]/chat/` +- Unread count badge in sidebar nav +- **Feature toggle**: Can be disabled per org via `feature_chat` setting + +### Database +```sql +CREATE TABLE matrix_credentials (user_id, matrix_user_id, access_token, device_id); +ALTER TABLE organizations ADD COLUMN matrix_space_id TEXT; +``` + +### Room for Improvement +- **Event-scoped channels**: Currently org-wide only; could auto-create channels per event/department. +- **File sharing**: No in-chat file upload. +- **Threads**: Matrix supports threads but UI doesn't expose them. +- **Push notifications**: Not implemented. + +--- + +## 10. Events + +### What It Is +The core entity of the platform. Events are projects that contain departments, budgets, schedules, sponsors, and more. + +### Problems It Solves +- Structured project management for event planning +- Status tracking (planning โ†’ active โ†’ completed โ†’ archived) +- Cross-department coordination + +### Database +```sql +CREATE TABLE events ( + id UUID PRIMARY KEY, + org_id UUID REFERENCES organizations(id), + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'planning', -- planning, active, completed, archived + start_date TIMESTAMPTZ, + end_date TIMESTAMPTZ, + venue_name TEXT, + venue_address TEXT, + color TEXT, + created_by UUID, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ +); +``` + +### Key Features +- **Status pipeline**: Planning โ†’ Active โ†’ Completed โ†’ Archived with tab filtering +- **Color coding**: Each event has a color (defaults to org's `default_event_color`) +- **Auto-folder creation**: Creating an event auto-creates `Events/[EventName]/` folder +- **Event detail page**: Overview with status, dates, venue, team, and navigation to sub-pages + +### API (`src/lib/api/events.ts`) +- `fetchEvents`, `fetchEventBySlug`, `createEvent`, `updateEvent`, `deleteEvent` +- `fetchEventDepartments`, `createDepartment`, `updateDepartment`, `deleteDepartment` +- `updateDepartmentPlannedBudget` + +### Room for Improvement +- **Event templates**: No way to clone an event structure. +- **Guest list / registration**: Not yet implemented (on roadmap). +- **Public event page**: No public-facing event page for attendees. + +--- + +## 11. Event Team & Departments + +### What It Is +Team management within events. Each event has departments (e.g., "Marketing", "Logistics") with assigned members. + +### Problems It Solves +- Clear ownership of event responsibilities +- Department-scoped modules (each dept gets its own dashboard) + +### Database +```sql +CREATE TABLE event_departments ( + id UUID PRIMARY KEY, + event_id UUID REFERENCES events(id), + name TEXT NOT NULL, + description TEXT, + color TEXT, + head_user_id UUID, + planned_budget NUMERIC DEFAULT 0, + created_at TIMESTAMPTZ +); + +CREATE TABLE event_members ( + id UUID PRIMARY KEY, + event_id UUID REFERENCES events(id), + user_id UUID, + department_id UUID REFERENCES event_departments(id), + role TEXT DEFAULT 'member' +); +``` + +### Key Features +- **Department creation**: Auto-creates folder + dashboard +- **Department head**: Assignable lead per department +- **Planned budget**: Per-department budget allocation +- **Member assignment**: Assign org members to specific departments + +### Room for Improvement +- **Shift scheduling**: No volunteer shift management. +- **Department templates**: Can't save/reuse department structures. + +--- + +## 12. Department Dashboard + +### What It Is +A composable, layout-configurable dashboard per department with drag-and-drop module panels. + +### Problems It Solves +- Each department needs different tools (marketing needs contacts, logistics needs schedules) +- Customizable workspace per team + +### Database +```sql +CREATE TABLE department_dashboards ( + id UUID PRIMARY KEY, + department_id UUID REFERENCES event_departments(id), + layout TEXT DEFAULT 'split' -- single, split, grid, focus_sidebar +); + +CREATE TABLE dashboard_panels ( + id UUID PRIMARY KEY, + dashboard_id UUID REFERENCES department_dashboards(id), + module TEXT NOT NULL, -- kanban, files, checklist, notes, schedule, contacts, budget, sponsors + position INTEGER, + config JSONB +); +``` + +### Layout Presets +- **Single**: One full-width panel +- **Split**: Two equal columns +- **Grid**: 2ร—2 grid +- **Focus + Sidebar**: Large left panel + narrow right panel + +### Auto-Creation +A PostgreSQL trigger (`on_department_created_setup_dashboard`) auto-creates a dashboard with default panels when a department is inserted. + +### Key Features +- **Add/remove modules**: Click to add any available module +- **Expand to fullscreen**: Each panel can expand to full-screen mode +- **Layout switching**: Change layout preset from dashboard header + +### Room for Improvement +- **Drag-and-drop panel reordering**: Currently position is fixed by creation order. +- **Custom panel sizing**: All panels are equal size within a layout. +- **Default modules from org settings**: The `default_dept_modules` org setting is stored but not yet consumed by the auto-create trigger. + +--- + +## 13. Checklists Module + +### What It Is +Task checklists within department dashboards. Multiple checklists per department, each with ordered items. + +### Problems It Solves +- Simple task tracking without full kanban overhead +- Pre-event preparation checklists + +### Database +```sql +CREATE TABLE department_checklists (id, department_id, title, position); +CREATE TABLE department_checklist_items (id, checklist_id, text, completed, position); +``` + +### Key Features +- Multiple checklists per department +- Add/rename/delete checklists +- Toggle item completion with progress bar +- Drag-to-reorder items + +### Component: `src/lib/components/modules/ChecklistWidget.svelte` + +--- + +## 14. Notes Module + +### What It Is +Rich-text notes per department with auto-save. + +### Problems It Solves +- Meeting notes, decision logs, and documentation per department +- No need for external note-taking tools + +### Database +```sql +CREATE TABLE department_notes (id, department_id, title, content, position, created_at, updated_at); +``` + +### Key Features +- Note list sidebar + textarea editor +- **Auto-save** with 500ms debounce +- Create/rename/delete notes + +### Component: `src/lib/components/modules/NotesWidget.svelte` + +### Room for Improvement +- **Rich text editor**: Currently plain textarea; could use TipTap like documents. +- **Collaborative editing**: No real-time collaboration. + +--- + +## 15. Schedule Module + +### What It Is +A timeline/program builder for event schedules with stages (rooms/tracks) and time blocks. + +### Problems It Solves +- Event program planning with multiple parallel tracks +- Visual timeline view of the event day + +### Database +```sql +CREATE TABLE schedule_stages (id, department_id, name, color, position); +CREATE TABLE schedule_blocks ( + id, department_id, stage_id, title, description, + start_time TIMESTAMPTZ, end_time TIMESTAMPTZ, + speaker TEXT, color TEXT +); +``` + +### Key Features +- **Stages bar**: Color-coded tracks (e.g., "Main Stage", "Workshop Room") +- **Time blocks**: Scheduled items with start/end times, speaker, description +- **Date grouping**: Blocks grouped by date +- **Color picker**: Per-block color customization + +### Component: `src/lib/components/modules/ScheduleWidget.svelte` + +### Room for Improvement +- **Drag-and-drop timeline**: Currently list-based; could use a visual timeline grid. +- **Conflict detection**: No warning when blocks overlap on the same stage. +- **Public schedule**: No attendee-facing schedule view. + +--- + +## 16. Contacts Module + +### What It Is +A searchable vendor/contact directory per department. + +### Problems It Solves +- Centralized vendor management for event logistics +- Quick access to contact info during event planning + +### Database +```sql +CREATE TABLE department_contacts ( + id, department_id, name, email, phone, company, + role TEXT, category TEXT, notes TEXT, website TEXT +); +``` + +### Categories +Predefined categories: Venue, Catering, AV/Tech, Security, Transport, Decoration, Photography, Entertainment, Printing, Accommodation, Sponsor, Speaker, Other. + +### Key Features +- Search by name/company/email +- Filter by category +- Expandable detail rows +- CRUD modal with all contact fields + +### Component: `src/lib/components/modules/ContactsWidget.svelte` + +### Room for Improvement +- **Org-level contacts**: Currently department-scoped; could have a shared org contact book. +- **Import/export**: No CSV import/export. +- **Deduplication**: No duplicate detection. + +--- + +## 17. Budget & Finances Module + +### What It Is +Income/expense tracking with categories, planned vs. actual amounts, and cross-department financial overview. + +### Problems It Solves +- Budget planning and tracking across departments +- Planned vs. actual variance analysis +- Receipt management + +### Database +```sql +CREATE TABLE budget_categories (id, event_id, name, color); +CREATE TABLE budget_items ( + id, event_id, department_id, category_id, + description TEXT, item_type TEXT, -- 'income' | 'expense' + planned_amount NUMERIC, actual_amount NUMERIC, + notes TEXT, receipt_document_id UUID +); +``` + +### Key Features +- **Overview/Income/Expense tabs**: Filtered views +- **Category management**: Color-coded budget categories +- **Planned vs. Actual**: Side-by-side comparison with diff column +- **Receipt linking**: Attach receipt documents to budget items +- **Department rollup**: Finances page shows cross-department totals +- **Sponsor allocation**: Link sponsor funds to specific departments +- **Currency formatting**: Uses org's currency setting via shared `formatCurrency()` utility + +### Components +- `src/lib/components/modules/BudgetWidget.svelte` โ€” department-level widget +- `src/routes/[orgSlug]/events/[eventSlug]/finances/+page.svelte` โ€” cross-department overview + +### API (`src/lib/api/budget.ts`) +- `fetchEventBudgetCategories`, `createBudgetCategory`, `deleteBudgetCategory` +- `fetchEventBudgetItems`, `createBudgetItem`, `updateBudgetItem`, `deleteBudgetItem` + +### Room for Improvement +- **Budget approval workflow**: No approval process for expenses. +- **Currency conversion**: No multi-currency support within a single org. +- **Export**: No PDF/Excel export of financial reports. +- **Recurring items**: No support for recurring budget items. + +--- + +## 18. Sponsors Module + +### What It Is +A lightweight CRM for managing event sponsors with tiers, status pipeline, deliverables tracking, and fund allocation. + +### Problems It Solves +- Sponsor relationship management +- Deliverable tracking (what was promised vs. delivered) +- Fund allocation across departments + +### Database +```sql +CREATE TABLE sponsor_tiers (id, event_id, name, amount, color, position); +CREATE TABLE sponsors ( + id, event_id, department_id, tier_id, + name, contact_name, contact_email, contact_phone, website, + status TEXT, -- prospect, contacted, negotiating, confirmed, declined, active, completed + amount NUMERIC, notes TEXT +); +CREATE TABLE sponsor_deliverables (id, sponsor_id, description, completed); +CREATE TABLE sponsor_allocations (id, event_id, sponsor_id, department_id, amount, notes); +``` + +### Status Pipeline +`prospect` โ†’ `contacted` โ†’ `negotiating` โ†’ `confirmed` โ†’ `declined` โ†’ `active` โ†’ `completed` + +### Key Features +- **Tier management**: Gold/Silver/Bronze with amounts and colors +- **Status pipeline**: Visual status badges with color coding +- **Deliverables checklist**: Per-sponsor deliverable tracking +- **Contact info**: Name, email, phone, website per sponsor +- **Filter**: By status and tier +- **Fund allocation**: Allocate sponsor funds to specific departments +- **Currency formatting**: Uses org's currency setting + +### Components +- `src/lib/components/modules/SponsorsWidget.svelte` โ€” department-level widget +- `src/routes/[orgSlug]/events/[eventSlug]/sponsors/+page.svelte` โ€” event-level overview + +### API (`src/lib/api/sponsors.ts`) +- `fetchEventSponsors`, `createSponsor`, `updateSponsor`, `deleteSponsor` +- `fetchEventSponsorTiers`, `createSponsorTier`, `deleteSponsorTier` +- `fetchEventDeliverables`, `createDeliverable`, `toggleDeliverable`, `deleteDeliverable` + +### Room for Improvement +- **Email integration**: No automated sponsor outreach. +- **Contract management**: No contract status tracking. +- **Sponsor portal**: No external-facing sponsor dashboard. + +--- + +## 19. Activity Tracking + +### What It Is +An audit log of all significant actions within an organization. + +### Database +```sql +CREATE TABLE activity_log ( + id UUID PRIMARY KEY, + org_id UUID, user_id UUID, + action TEXT, entity_type TEXT, entity_id UUID, entity_name TEXT, + metadata JSONB, created_at TIMESTAMPTZ +); +``` + +### Key Features +- Tracks: create, update, delete actions on all entity types +- Displayed on org overview page as "Recent Activity" +- Shows user name, action, entity, and timestamp + +### API (`src/lib/api/activity.ts`) +- `logActivity(supabase, params)` โ€” creates activity log entry +- `fetchRecentActivity(supabase, orgId, limit)` โ€” fetches recent entries + +### Room for Improvement +- **Granular tracking**: Not all actions are logged (e.g., budget item edits). +- **Notifications**: No push/email notifications based on activity. +- **Filtering**: No filtering by entity type or user on the overview page. + +--- + +## 20. Tags System + +### What It Is +Organization-wide tags that can be applied to various entities for categorization. + +### Database +```sql +CREATE TABLE org_tags (id, org_id, name, color); +``` + +### Key Features +- Create/edit/delete tags with colors +- Managed in Settings โ†’ Tags tab + +### Room for Improvement +- **Tag application**: Tags exist but aren't yet applied to events, documents, or other entities. +- **Tag filtering**: No cross-entity filtering by tag. + +--- + +## 21. Roles & Permissions + +### What It Is +A flexible role-based access control system with custom roles and granular permissions. + +### Database +```sql +CREATE TABLE org_roles ( + id UUID PRIMARY KEY, + org_id UUID, name TEXT, color TEXT, + permissions TEXT[], + is_default BOOLEAN, is_system BOOLEAN, position INTEGER +); + +-- org_members has role (text) and role_id (FK to org_roles) +``` + +### Built-in Roles +- **Owner**: Full access, can delete org +- **Admin**: Full access except org deletion +- **Editor**: Can create/edit content +- **Viewer**: Read-only access + +### Custom Roles +Admins can create custom roles with specific permissions: +- `documents.view`, `documents.edit`, `documents.delete` +- `calendar.view`, `calendar.edit` +- `settings.view`, `settings.edit` +- `members.view`, `members.invite`, `members.remove` + +### Implementation +- `src/lib/utils/permissions.ts` โ€” `hasPermission(userRole, userPermissions, permission)` utility +- Layout uses `canAccess()` to conditionally show nav items + +### Room for Improvement +- **Event-level roles**: Permissions are org-wide; no per-event role assignment. +- **Department-level permissions**: No per-department access control. + +--- + +## 22. Google Calendar Integration + +### What It Is +Two-way sync between Root's calendar and Google Calendar. + +### Implementation +- OAuth2 flow for connecting Google account +- Org-level Google Calendar connection (stored in org settings) +- Syncs events bidirectionally +- Subscribe URL for external calendar apps + +### API (`src/lib/api/google-calendar.ts`) +- `fetchGoogleCalendarEvents` โ€” fetches events from connected Google Calendar +- `getCalendarSubscribeUrl` โ€” generates iCal subscribe URL +- Push notifications for real-time sync + +### Room for Improvement +- **Per-user sync**: Currently org-level only. +- **Outlook/iCal**: Only Google Calendar supported. +- **Conflict resolution**: No handling of concurrent edits. + +--- + +## 23. Internationalization (i18n) + +### What It Is +Full UI translation support using Paraglide v2. + +### Supported Languages +- **English** (`en`) โ€” base locale +- **Estonian** (`et`) + +### Implementation +- ~248 message keys covering all UI strings +- Import pattern: `import * as m from "$lib/paraglide/messages"` +- Parameterized messages: `{m.key({ param: value })}` +- Config: `project.inlang/settings.json` +- Vite plugin: `paraglideVitePlugin` + +### Key Files +- `messages/en.json` โ€” English translations +- `messages/et.json` โ€” Estonian translations +- `src/lib/paraglide/` โ€” generated output + +### Room for Improvement +- **Language picker**: No in-app language switcher (uses browser locale). +- **More languages**: Only 2 languages supported. +- **RTL support**: No right-to-left language support. + +--- + +## 24. Platform Admin + +### What It Is +A super-admin panel for managing all organizations on the platform. + +### Route: `/admin/` + +### Database +```sql +-- Migration 030: platform_admin +-- Migration 031: platform_admin_rls +``` + +### Key Features +- View all organizations +- Platform-level statistics + +### Room for Improvement +- **User management**: No platform-level user management. +- **Billing**: No subscription/billing management. +- **Analytics**: No usage analytics dashboard. + +--- + +## 25. Testing Strategy + +### Unit Tests (Vitest) +- **Location**: `src/lib/api/*.test.ts` +- **Coverage**: All API modules have unit tests +- **Count**: 112+ tests across 9 files +- **Run**: `npm test` + +### E2E Tests (Playwright) +- **Location**: `tests/e2e/` +- **Key files**: `features.spec.ts`, `events.spec.ts` +- **Setup**: Auth setup project with `storageState` persistence +- **Important**: Uses `waitUntil: 'networkidle'` for Svelte 5 hydration timing + +### Key Testing Notes +- Svelte 5 uses event delegation โ€” handlers only work after hydration +- Serial test suites can cascade failures if early tests fail +- Database triggers (auto-create dashboard) can be flaky in test environments + +--- + +## 26. Database Migrations + +### Migration History +| # | Name | Description | +|---|------|-------------| +| 001 | initial_schema | Organizations, org_members, profiles, documents | +| 002 | card_checklists | Kanban card checklists | +| 003 | google_calendar | Google Calendar integration | +| 004 | org_google_calendar | Org-level Google Calendar | +| 005 | roles_and_invites | Custom roles, invites | +| 006 | simplify_google_calendar | Simplified Google Calendar schema | +| 007 | org_theme | Theme color, icon_url | +| 008 | kanban_enhancements | Kanban improvements | +| 009 | activity_tracking | Activity log table | +| 010 | tags_system | Org tags | +| 011 | teams_roles | Team roles | +| 012 | task_comments | Kanban card comments | +| 013 | kanban_labels | Kanban labels | +| 014 | document_enhancements | Document improvements | +| 015 | migrate_kanban_to_documents | Kanban as document type | +| 016 | document_locks | Document locking | +| 017 | avatars_storage | Avatar storage bucket | +| 018 | user_avatars_storage | User avatar storage | +| 019 | fix_org_members_profiles_fk | FK fix | +| 020 | matrix_credentials | Matrix chat credentials | +| 021 | org_matrix_space | Org Matrix space | +| 022 | events | Events table | +| 023 | event_team_management | Departments, event members | +| 024 | profile_extended_fields | Extended profile fields | +| 025 | event_tasks | Event tasks | +| 026 | department_dashboards | Dashboard, panels, checklists, notes | +| 027 | remove_default_seeds | Remove seed data | +| 028 | schedule_and_contacts | Schedule stages/blocks, contacts | +| 029 | budget_and_sponsors | Budget, sponsors, deliverables | +| 030 | platform_admin | Platform admin | +| 031 | platform_admin_rls | Platform admin RLS | +| 032 | document_event_dept_fks | Document event/dept FKs | +| 033 | files_storage | File storage | +| 034 | layout_triple | Triple layout preset | +| 035 | budget_receipts | Budget receipt linking | +| 036 | finance_enhancements | Finance improvements | +| 037 | org_settings | Organization settings expansion | + +### DB Workflow +```bash +npm run db:push # Push pending migrations +npm run db:types # Regenerate TypeScript types +npm run db:migrate # Push + regenerate in one step +``` + +--- + +## Summary of Improvement Priorities + +### High Priority +1. **Shared `formatDate()` utility** consuming org's `date_format` and `timezone` settings +2. **Wire `week_start_day`** into Calendar grid generation +3. **Wire `default_dept_modules`** into the department auto-create trigger +4. **Feature toggles** should also hide modules within department dashboards, not just sidebar nav + +### Medium Priority +5. **Event templates** โ€” clone event structures +6. **Guest list / registration** โ€” attendee management +7. **Document search** โ€” full-text search across documents +8. **Export** โ€” PDF/Excel export for budgets and sponsor reports + +### Low Priority +9. **Real-time collaboration** on documents +10. **SSO / 2FA** authentication +11. **Push notifications** for activity +12. **Public event pages** for attendees diff --git a/messages/en.json b/messages/en.json index 5e688e9..361a6ee 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8,13 +8,14 @@ "user_menu_account_settings": "Account Settings", "user_menu_switch_org": "Switch Organization", "user_menu_logout": "Log Out", - "btn_new": "+ New", + "btn_new": "New", "btn_create": "Create", "btn_cancel": "Cancel", "btn_save": "Save", "btn_delete": "Delete", "btn_edit": "Edit", "btn_close": "Close", + "btn_creating": "Creating...", "btn_upload": "Upload", "btn_remove": "Remove", "login_title": "Welcome to Root", @@ -24,7 +25,7 @@ "login_email_label": "Email", "login_email_placeholder": "you@example.com", "login_password_label": "Password", - "login_password_placeholder": "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข", + "login_password_placeholder": "ฤโ‚ฌยขฤโ‚ฌยขฤโ‚ฌยขฤโ‚ฌยขฤโ‚ฌยขฤโ‚ฌยขฤโ‚ฌยขฤโ‚ฌยข", "login_btn_login": "Log In", "login_btn_signup": "Sign Up", "login_or_continue": "or continue with", @@ -56,7 +57,7 @@ "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_empty": "No files yet. Click New to create one.", "files_toggle_view": "Toggle view", "kanban_title": "Kanban", "kanban_create_board": "Create Board", @@ -110,6 +111,29 @@ "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_social_title": "Social & Links", + "settings_social_desc": "Add your organization's website and social media links.", + "settings_social_website": "Website", + "settings_social_website_placeholder": "https://yourorg.com", + "settings_social_instagram": "Instagram", + "settings_social_instagram_placeholder": "https://instagram.com/yourorg", + "settings_social_facebook": "Facebook", + "settings_social_facebook_placeholder": "https://facebook.com/yourorg", + "settings_social_discord": "Discord", + "settings_social_discord_placeholder": "https://discord.gg/yourorg", + "settings_social_linkedin": "LinkedIn", + "settings_social_linkedin_placeholder": "https://linkedin.com/company/yourorg", + "settings_social_x": "X (Twitter)", + "settings_social_x_placeholder": "https://x.com/yourorg", + "settings_social_youtube": "YouTube", + "settings_social_youtube_placeholder": "https://youtube.com/@yourorg", + "settings_social_tiktok": "TikTok", + "settings_social_tiktok_placeholder": "https://tiktok.com/@yourorg", + "settings_social_fienta": "Fienta", + "settings_social_fienta_placeholder": "https://fienta.com/yourorg", + "settings_social_twitch": "Twitch", + "settings_social_twitch_placeholder": "https://twitch.tv/yourorg", + "settings_social_save": "Save Links", "settings_tags_title": "Organization Tags", "settings_tags_desc": "Manage tags that can be used across all Kanban boards.", "settings_tags_create": "Create Tag", @@ -118,7 +142,7 @@ "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_invited_as": "Invited as {role} ฤโ‚ฌยข Expires {date}", "settings_members_copy_link": "Copy Link", "settings_members_unknown": "Unknown User", "settings_members_no_name": "No name", @@ -160,7 +184,7 @@ "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_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", @@ -410,5 +434,286 @@ "dept_files_open": "Open Files", "dept_files_desc": "Department files and documents", "dept_quick_add": "Quick add", - "dept_modules_label": "Modules" + "dept_modules_label": "Modules", + "dept_dashboard_collapse": "Collapse", + "dept_dashboard_move_left": "Move left", + "dept_dashboard_move_right": "Move right", + "dept_dashboard_layout_1col": "1 Column", + "dept_dashboard_layout_2col": "2 Columns", + "dept_dashboard_layout_3col": "3 Columns", + "dept_dashboard_layout_grid": "2x2 Grid", + "dept_module_kanban": "Kanban", + "dept_module_files": "Files", + "dept_module_checklist": "Checklist", + "dept_module_notes": "Notes", + "dept_module_schedule": "Schedule", + "dept_module_contacts": "Contacts", + "dept_module_budget": "Budget", + "dept_module_sponsors": "Sponsors", + "dept_module_map": "Map", + "toast_error_update_layout": "Failed to update layout", + "toast_error_add_module": "Failed to add module", + "toast_error_remove_module": "Failed to remove module", + "toast_error_reorder_modules": "Failed to reorder modules", + "toast_error_add_item": "Failed to add item", + "toast_error_update_item": "Failed to update item", + "toast_error_delete_item": "Failed to delete item", + "toast_error_create_checklist": "Failed to create checklist", + "toast_error_delete_checklist": "Failed to delete checklist", + "toast_error_rename_checklist": "Failed to rename checklist", + "toast_error_delete_contact": "Failed to delete contact", + "toast_error_create_category": "Failed to create category", + "toast_error_delete_category": "Failed to delete category", + "toast_error_create_budget_item": "Failed to create budget item", + "toast_error_update_budget_item": "Failed to update budget item", + "toast_error_delete_budget_item": "Failed to delete budget item", + "toast_error_upload_receipt": "Failed to upload receipt", + "toast_success_receipt_attached": "Receipt \"{name}\" attached", + "toast_error_create_tier": "Failed to create tier", + "toast_error_delete_tier": "Failed to delete tier", + "toast_error_create_sponsor": "Failed to create sponsor", + "toast_error_update_sponsor": "Failed to update sponsor", + "toast_error_delete_sponsor": "Failed to delete sponsor", + "toast_error_create_deliverable": "Failed to create deliverable", + "toast_error_update_deliverable": "Failed to update deliverable", + "toast_error_delete_deliverable": "Failed to delete deliverable", + "checklist_rename": "Rename", + "checklist_delete": "Delete checklist", + "checklist_add_item_placeholder": "Add item...", + "checklist_name_placeholder": "Checklist name...", + "checklist_no_items": "No checklists yet", + "checklist_add": "Add checklist", + "notes_new": "New note", + "notes_title_placeholder": "Note title...", + "notes_placeholder": "Start writing...", + "notes_no_notes": "No notes yet", + "notes_select": "Select a note", + "notes_export_document": "Export as document", + "notes_delete": "Delete note", + "notes_exported": "Exported \"{title}\" as document", + "notes_export_error": "Failed to export note", + "schedule_timeline": "Timeline", + "schedule_list": "List", + "schedule_add_block": "Add Block", + "schedule_manage_stages": "Manage Stages", + "schedule_no_blocks": "No schedule blocks yet", + "schedule_add_first": "Add your first block to start building the schedule.", + "schedule_all_day": "All day", + "schedule_no_stage": "No stage", + "schedule_add_stage_title": "Add Stage / Room", + "schedule_stage_name_placeholder": "e.g. Main Stage", + "schedule_add_stage": "Add Stage", + "schedule_existing_stages": "Existing Stages", + "schedule_no_stages": "No stages yet", + "schedule_add_block_title": "Add Schedule Block", + "schedule_edit_block_title": "Edit Schedule Block", + "schedule_block_title_label": "Title", + "schedule_block_title_placeholder": "e.g. Opening Ceremony", + "schedule_block_speaker_label": "Speaker / Host", + "schedule_block_speaker_placeholder": "e.g. John Doe", + "schedule_block_start_label": "Start Time", + "schedule_block_end_label": "End Time", + "schedule_block_stage_label": "Stage / Room", + "schedule_block_no_stage": "No stage", + "schedule_block_description_label": "Description", + "schedule_block_description_placeholder": "Optional description...", + "schedule_block_color_label": "Color", + "schedule_block_delete": "Delete Block", + "contacts_search_placeholder": "Search contacts...", + "contacts_add": "Add Contact", + "contacts_no_contacts": "No contacts yet", + "contacts_add_first": "Add your first contact to build your directory.", + "contacts_no_results": "No contacts match your search", + "contacts_category_all": "All", + "contacts_category_venue": "Venue", + "contacts_category_catering": "Catering", + "contacts_category_av": "AV / Tech", + "contacts_category_security": "Security", + "contacts_category_transport": "Transport", + "contacts_category_entertainment": "Entertainment", + "contacts_category_media": "Media", + "contacts_category_sponsor": "Sponsor", + "contacts_category_other": "Other", + "contacts_add_title": "Add Contact", + "contacts_edit_title": "Edit Contact", + "contacts_name_label": "Name", + "contacts_name_placeholder": "Contact name", + "contacts_role_label": "Role / Title", + "contacts_role_placeholder": "e.g. Event Manager", + "contacts_company_label": "Company", + "contacts_company_placeholder": "Company name", + "contacts_email_label": "Email", + "contacts_email_placeholder": "email@example.com", + "contacts_phone_label": "Phone", + "contacts_phone_placeholder": "+372 ...", + "contacts_website_label": "Website", + "contacts_website_placeholder": "https://...", + "contacts_category_label": "Category", + "contacts_notes_label": "Notes", + "contacts_notes_placeholder": "Optional notes...", + "contacts_delete_confirm": "Delete this contact?", + "budget_income": "Income", + "budget_expenses": "Expenses", + "budget_planned": "Planned: {amount}", + "budget_planned_balance": "Planned Balance", + "budget_actual_balance": "Actual Balance", + "budget_view_all": "All", + "budget_view_income": "Income", + "budget_view_expenses": "Expenses", + "budget_add_category": "Category", + "budget_add_item": "Add Item", + "budget_no_items": "No budget items yet", + "budget_col_type": "Type", + "budget_col_description": "Description", + "budget_col_category": "Category", + "budget_col_planned": "Planned", + "budget_col_actual": "Actual", + "budget_col_diff": "Diff", + "budget_col_receipt": "Receipt", + "budget_uncategorized": "Uncategorized", + "budget_total": "Total", + "budget_missing_receipt": "Missing invoice/receipt - click to upload", + "budget_missing_receipt_short": "Missing invoice/receipt", + "budget_receipt_attached": "Receipt attached", + "budget_add_item_title": "Add Budget Item", + "budget_edit_item_title": "Edit Budget Item", + "budget_description_label": "Description", + "budget_description_placeholder": "e.g. Venue rental", + "budget_type_label": "Type", + "budget_type_expense": "Expense", + "budget_type_income": "Income", + "budget_category_label": "Category", + "budget_planned_amount_label": "Planned Amount", + "budget_actual_amount_label": "Actual Amount", + "budget_notes_label": "Notes", + "budget_notes_placeholder": "Optional notes...", + "budget_add_category_title": "Add Category", + "budget_category_name_label": "Name", + "budget_category_name_placeholder": "e.g. Venue", + "budget_category_color_label": "Color", + "budget_select_color": "Select color {color}", + "budget_existing_categories": "Existing Categories", + "sponsors_search_placeholder": "Search sponsors...", + "sponsors_add_tier": "Add Tier", + "sponsors_add_sponsor": "Add Sponsor", + "sponsors_no_sponsors": "No sponsors yet", + "sponsors_add_first": "Add sponsor tiers and start tracking your sponsors.", + "sponsors_no_results": "No sponsors match your filters", + "sponsors_filter_all_statuses": "All Statuses", + "sponsors_filter_all_tiers": "All Tiers", + "sponsors_status_prospect": "Prospect", + "sponsors_status_contacted": "Contacted", + "sponsors_status_confirmed": "Confirmed", + "sponsors_status_declined": "Declined", + "sponsors_status_active": "Active", + "sponsors_contact_label": "Contact", + "sponsors_amount_label": "Amount", + "sponsors_deliverables_label": "Deliverables", + "sponsors_deliverable_placeholder": "Add deliverable...", + "sponsors_no_deliverables": "No deliverables yet", + "sponsors_notes_label": "Notes", + "sponsors_add_tier_title": "Add Sponsor Tier", + "sponsors_tier_name_label": "Tier Name", + "sponsors_tier_name_placeholder": "e.g. Gold", + "sponsors_tier_amount_label": "Minimum Amount", + "sponsors_tier_color_label": "Color", + "sponsors_existing_tiers": "Existing Tiers", + "sponsors_no_tiers": "No tiers yet", + "sponsors_add_sponsor_title": "Add Sponsor", + "sponsors_edit_sponsor_title": "Edit Sponsor", + "sponsors_name_label": "Sponsor Name", + "sponsors_name_placeholder": "Company name", + "sponsors_tier_label": "Tier", + "sponsors_no_tier": "No tier", + "sponsors_status_label": "Status", + "sponsors_sponsor_amount_label": "Amount (ฤโ€šยฌ)", + "sponsors_sponsor_amount_placeholder": "0", + "sponsors_contact_name_label": "Contact Name", + "sponsors_contact_name_placeholder": "Contact person", + "sponsors_contact_email_label": "Contact Email", + "sponsors_contact_email_placeholder": "email@example.com", + "sponsors_contact_phone_label": "Contact Phone", + "sponsors_contact_phone_placeholder": "+372 ...", + "sponsors_website_label": "Website", + "sponsors_website_placeholder": "https://...", + "sponsors_notes_placeholder": "Optional notes...", + "sponsors_delete_confirm": "Delete this sponsor?", + "sponsors_select_color": "Select color {color}", + "files_widget_loading": "Loading files...", + "files_widget_no_folder": "Department folder not yet created", + "files_widget_empty": "No files yet", + "files_widget_full_view": "Full View", + "files_widget_create_title": "Create New", + "files_widget_type_document": "Document", + "files_widget_type_folder": "Folder", + "files_widget_type_kanban": "Kanban Board", + "files_widget_name_label": "Name", + "files_widget_doc_placeholder": "Document name", + "files_widget_folder_placeholder": "Folder name", + "files_widget_kanban_placeholder": "Board name", + "files_widget_drop_files": "Drop files to upload", + "files_widget_uploading": "Uploading {name}...", + "kanban_widget_loading": "Loading boards...", + "kanban_widget_no_folder": "Department folder not yet created", + "kanban_widget_no_boards": "No kanban boards yet", + "kanban_widget_create": "Create Board", + "kanban_widget_create_title": "Create Kanban Board", + "kanban_widget_name_label": "Board Name", + "kanban_widget_name_placeholder": "e.g. Task Tracker", + "finances_title": "Event Finances", + "finances_subtitle": "Budget overview across all departments", + "finances_total_income": "Total Income", + "finances_total_expenses": "Total Expenses", + "finances_net_balance": "Net Balance", + "finances_missing_receipts": "Missing Receipts", + "finances_items_without_receipts": "{count} items without receipts", + "finances_planned": "planned", + "finances_view_by_dept": "By Department", + "finances_view_by_category": "By Category", + "finances_filter_all": "All", + "finances_filter_income": "Income", + "finances_filter_expenses": "Expenses", + "finances_no_items": "No budget items yet", + "finances_no_items_desc": "Budget items will appear here once departments add them.", + "finances_col_description": "Description", + "finances_col_department": "Department", + "finances_col_category": "Category", + "finances_col_planned": "Planned", + "finances_col_actual": "Actual", + "finances_col_diff": "Diff", + "finances_col_receipt": "Receipt", + "finances_uncategorized": "Uncategorized", + "finances_no_department": "No Department", + "finances_group_total": "Subtotal", + "page_chat": "Chat", + "page_department": "Department", + "page_invite": "Invitation", + "map_tool_grab": "Grab", + "map_tool_select": "Select", + "map_tool_pin": "Pin", + "map_tool_polygon": "Polygon", + "map_tool_rectangle": "Rectangle", + "map_add_layer": "Add Map Layer", + "map_add_image_layer": "Add Image Map Layer", + "map_layer_name": "Layer Name", + "map_street_map": "Street Map", + "map_custom_image": "Custom Image", + "map_osm_tiles": "OpenStreetMap tiles", + "map_upload_venue": "Upload venue map", + "map_choose_image": "Choose image file", + "map_uploading": "Uploading...", + "map_image_url": "Image URL", + "map_image_desc": "Upload a venue floor plan or map image. Aspect ratio will be preserved.", + "map_objects": "Objects", + "map_no_objects": "No objects yet", + "map_export_png": "Export PNG", + "map_rename_layer": "Rename Layer", + "map_edit_pin": "Edit Pin", + "map_add_pin": "Add Pin", + "map_edit_shape": "Edit Shape", + "map_label": "Label", + "map_description": "Description", + "map_color": "Color", + "map_pen_hint_points": "{count} point(s)", + "map_pen_hint_close": "click near first point to close, or press Esc to cancel" } \ No newline at end of file diff --git a/messages/et.json b/messages/et.json index 446b020..0ad31ec 100644 --- a/messages/et.json +++ b/messages/et.json @@ -7,28 +7,28 @@ "nav_kanban": "Kanban", "user_menu_account_settings": "Konto seaded", "user_menu_switch_org": "Vaheta organisatsiooni", - "user_menu_logout": "Logi vรคlja", - "btn_new": "+ Uus", + "user_menu_logout": "Logi vฤ†ยคlja", + "btn_new": "Uus", "btn_create": "Loo", - "btn_cancel": "Tรผhista", + "btn_cancel": "Tฤ†ยผhista", "btn_save": "Salvesta", "btn_delete": "Kustuta", "btn_edit": "Muuda", "btn_close": "Sulge", - "btn_upload": "Laadi รผles", + "btn_upload": "Laadi ฤ†ยผles", "btn_remove": "Eemalda", "login_title": "Tere tulemast Rooti", - "login_subtitle": "Sinu meeskonna tรถรถruum dokumentide, tahvlite ja kalendrite jaoks.", + "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_email_placeholder": "sina@nฤ†ยคide.ee", "login_password_label": "Parool", - "login_password_placeholder": "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข", + "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_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", @@ -38,9 +38,9 @@ "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_label": "URL-i lฤ†ยผhend", "org_selector_slug_placeholder": "minu-meeskond", - "org_overview": "Organisatsiooni รผlevaade", + "org_overview": "Organisatsiooni ฤ†ยผlevaade", "files_title": "Failid", "files_breadcrumb_home": "Avaleht", "files_create_title": "Loo uus", @@ -51,48 +51,48 @@ "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_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_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_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_column_name_placeholder": "nt Teha, Tฤ†ยถฤ†ยถs, Valmis", "kanban_add_card": "Lisa kaart", - "kanban_card_details": "Kaardi รผksikasjad", + "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_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_empty": "Kanban tahvleid hallatakse nฤ†ยผฤ†ยผd Failides", "kanban_go_to_files": "Mine Failidesse", - "kanban_select_board": "Vali tahvel รผlalt", + "kanban_select_board": "Vali tahvel ฤ†ยผlalt", "calendar_title": "Kalender", "calendar_subscribe": "Telli kalender", - "calendar_refresh": "Vรคrskenda sรผndmusi", + "calendar_refresh": "Vฤ†ยคrskenda sฤ†ยผndmusi", "calendar_settings": "Kalendri seaded", - "calendar_create_event": "Loo sรผndmus", - "calendar_edit_event": "Muuda sรผndmust", + "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_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_general": "ฤ†ยœldine", "settings_tab_members": "Liikmed", "settings_tab_roles": "Rollid", "settings_tab_tags": "Sildid", @@ -101,24 +101,47 @@ "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": "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_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_social_title": "Sotsiaalmeedia ja lingid", + "settings_social_desc": "Lisa oma organisatsiooni veebileht ja sotsiaalmeedia lingid.", + "settings_social_website": "Veebileht", + "settings_social_website_placeholder": "https://sinuorg.ee", + "settings_social_instagram": "Instagram", + "settings_social_instagram_placeholder": "https://instagram.com/sinuorg", + "settings_social_facebook": "Facebook", + "settings_social_facebook_placeholder": "https://facebook.com/sinuorg", + "settings_social_discord": "Discord", + "settings_social_discord_placeholder": "https://discord.gg/sinuorg", + "settings_social_linkedin": "LinkedIn", + "settings_social_linkedin_placeholder": "https://linkedin.com/company/sinuorg", + "settings_social_x": "X (Twitter)", + "settings_social_x_placeholder": "https://x.com/sinuorg", + "settings_social_youtube": "YouTube", + "settings_social_youtube_placeholder": "https://youtube.com/@sinuorg", + "settings_social_tiktok": "TikTok", + "settings_social_tiktok_placeholder": "https://tiktok.com/@sinuorg", + "settings_social_fienta": "Fienta", + "settings_social_fienta_placeholder": "https://fienta.com/sinuorg", + "settings_social_twitch": "Twitch", + "settings_social_twitch_placeholder": "https://twitch.tv/sinuorg", + "settings_social_save": "Salvesta lingid", "settings_tags_title": "Organisatsiooni sildid", - "settings_tags_desc": "Halda silte, mida saab kasutada kรตigil Kanban tahvlitel.", + "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_invited_as": "Kutsutud kui {role} ฤโ‚ฌยข Aegub {date}", "settings_members_copy_link": "Kopeeri link", "settings_members_unknown": "Tundmatu kasutaja", "settings_members_no_name": "Nimi puudub", @@ -126,7 +149,7 @@ "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_email_placeholder": "kolleeg@nฤ†ยคide.ee", "settings_invite_role": "Roll", "settings_invite_send": "Saada kutse", "settings_invite_role_viewer": "Vaataja - Saab sisu vaadata", @@ -135,42 +158,42 @@ "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_desc": "Loo kohandatud rolle kindlate ฤ†ยตigustega.", "settings_roles_create": "Loo roll", "settings_roles_edit": "Muuda rolli", - "settings_roles_system": "Sรผsteemne", + "settings_roles_system": "Sฤ†ยผsteemne", "settings_roles_default": "Vaikimisi", - "settings_roles_all_perms": "Kรตik รตigused", + "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_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_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_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_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", + "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_sync_google": "Sฤ†ยผnkrooni Google", "account_remove_photo": "Eemalda foto", "account_display_name": "Kuvatav nimi", "account_display_name_placeholder": "Sinu nimi", @@ -180,16 +203,16 @@ "account_discord": "Discord", "account_discord_placeholder": "kasutajanimi", "account_contact_info": "Kontakt ja suurused", - "account_shirt_size": "Sรคrgi suurus", + "account_shirt_size": "Sฤ†ยคrgi suurus", "account_hoodie_size": "Pusa suurus", "account_size_placeholder": "Vali suurus", "account_save_profile": "Salvesta profiil", - "account_appearance": "Vรคlimus", + "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_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", @@ -197,11 +220,11 @@ "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_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", + "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", @@ -209,33 +232,33 @@ "editor_placeholder": "Alusta kirjutamist...", "editor_bold": "Paks (Ctrl+B)", "editor_italic": "Kursiiv (Ctrl+I)", - "editor_strikethrough": "Lรคbikriipsutus", - "editor_bullet_list": "Tรคpploend", + "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.", + "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?", + "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", + "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", @@ -243,11 +266,11 @@ "overview_quick_links": "Kiirlingid", "activity_title": "Viimane tegevus", "activity_empty": "Viimast tegevust pole veel.", - "activity_created": "{user} lรตi {entityType} \"{name}\"", + "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_renamed": "{user} nimetas ฤ†ยผmber {entityType} \"{name}\"", "activity_just_now": "Just praegu", "activity_minutes_ago": "{count} min tagasi", "activity_hours_ago": "{count}t tagasi", @@ -260,88 +283,88 @@ "entity_member": "liikme", "entity_role": "rolli", "entity_invite": "kutse", - "entity_event": "รผrituse", - "nav_events": "รœritused", + "entity_event": "ฤ†ยผrituse", + "nav_events": "ฤ†ยœritused", "nav_chat": "Vestlus", "chat_title": "Vestlus", - "chat_subtitle": "Meeskonna sรตnumid ja suhtlus", - "events_title": "รœritused", - "events_subtitle": "Korralda ja halda oma รผritusi", - "events_new": "Uus รผritus", - "events_create": "Loo รผritus", - "events_empty_title": "รœritusi pole veel", - "events_empty_desc": "Loo oma esimene รผritus alustamiseks", - "events_no_dates": "Kuupรคevad mรครคramata", - "events_tab_all": "Kรตik รผritused", + "chat_subtitle": "Meeskonna sฤ†ยตnumid ja suhtlus", + "events_title": "ฤ†ยœritused", + "events_subtitle": "Korralda ja halda oma ฤ†ยผritusi", + "events_new": "Uus ฤ†ยผritus", + "events_create": "Loo ฤ†ยผritus", + "events_empty_title": "ฤ†ยœritusi pole veel", + "events_empty_desc": "Loo oma esimene ฤ†ยผritus alustamiseks", + "events_no_dates": "Kuupฤ†ยคevad mฤ†ยคฤ†ยคramata", + "events_tab_all": "Kฤ†ยตik ฤ†ยผritused", "events_tab_planning": "Planeerimisel", "events_tab_active": "Aktiivne", - "events_tab_completed": "Lรตpetatud", + "events_tab_completed": "Lฤ†ยตpetatud", "events_tab_archived": "Arhiveeritud", "events_status_planning": "Planeerimisel", "events_status_active": "Aktiivne", - "events_status_completed": "Lรตpetatud", + "events_status_completed": "Lฤ†ยตpetatud", "events_status_archived": "Arhiveeritud", - "events_form_name": "รœrituse nimi", + "events_form_name": "ฤ†ยœrituse nimi", "events_form_name_placeholder": "nt Suvekonverents 2026", "events_form_description": "Kirjeldus", - "events_form_description_placeholder": "รœrituse lรผhikirjeldus...", - "events_form_start_date": "Alguskuupรคev", - "events_form_end_date": "Lรตppkuupรคev", + "events_form_description_placeholder": "ฤ†ยœrituse lฤ†ยผhikirjeldus...", + "events_form_start_date": "Alguskuupฤ†ยคev", + "events_form_end_date": "Lฤ†ยตppkuupฤ†ยคev", "events_form_venue": "Toimumiskoht", "events_form_venue_placeholder": "nt Konverentsikeskus", "events_form_venue_address_placeholder": "Toimumiskoha aadress", - "events_form_color": "Vรคrv", - "events_form_select_color": "Vali vรคrv {color}", + "events_form_color": "Vฤ†ยคrv", + "events_form_select_color": "Vali vฤ†ยคrv {color}", "events_creating": "Loomine...", "events_saving": "Salvestamine...", "events_deleting": "Kustutamine...", - "events_updated": "รœritus uuendatud", - "events_created": "รœritus \"{name}\" loodud", - "events_deleted": "รœritus kustutatud", - "events_delete_title": "Kustuta รผritus?", - "events_delete_desc": "See kustutab jรครคdavalt รผrituse {name} ja kรตik selle andmed. Seda toimingut ei saa tagasi vรตtta.", - "events_delete_confirm": "Kustuta รผritus", - "events_days_ago": "{count} pรคeva tagasi", - "events_today": "Tรคna!", + "events_updated": "ฤ†ยœritus uuendatud", + "events_created": "ฤ†ยœritus \"{name}\" loodud", + "events_deleted": "ฤ†ยœritus kustutatud", + "events_delete_title": "Kustuta ฤ†ยผritus?", + "events_delete_desc": "See kustutab jฤ†ยคฤ†ยคdavalt ฤ†ยผrituse {name} ja kฤ†ยตik selle andmed. Seda toimingut ei saa tagasi vฤ†ยตtta.", + "events_delete_confirm": "Kustuta ฤ†ยผritus", + "events_days_ago": "{count} pฤ†ยคeva tagasi", + "events_today": "Tฤ†ยคna!", "events_tomorrow": "Homme", - "events_in_days": "{count} pรคeva pรคrast", - "events_overview": "รœlevaade", + "events_in_days": "{count} pฤ†ยคeva pฤ†ยคrast", + "events_overview": "ฤ†ยœlevaade", "events_modules": "Moodulid", - "events_details": "รœrituse andmed", - "events_start_date": "Alguskuupรคev", - "events_end_date": "Lรตppkuupรคev", + "events_details": "ฤ†ยœrituse andmed", + "events_start_date": "Alguskuupฤ†ยคev", + "events_end_date": "Lฤ†ยตppkuupฤ†ยคev", "events_venue": "Toimumiskoht", - "events_not_set": "Mรครคramata", - "events_all_events": "Kรตik รผritused", + "events_not_set": "Mฤ†ยคฤ†ยคramata", + "events_all_events": "Kฤ†ยตik ฤ†ยผritused", "events_team": "Meeskond", "events_team_count": "Meeskond ({count})", "events_team_manage": "Halda", - "events_team_empty": "Meeskonnaliikmeid pole veel mรครคratud", + "events_team_empty": "Meeskonnaliikmeid pole veel mฤ†ยคฤ†ยคratud", "events_more_members": "+{count} veel", - "events_mod_tasks": "รœlesanded", - "events_mod_tasks_desc": "Halda รผlesandeid, verstaposte ja edenemist", + "events_mod_tasks": "ฤ†ยœlesanded", + "events_mod_tasks_desc": "Halda ฤ†ยผlesandeid, verstaposte ja edenemist", "events_mod_files": "Failid", "events_mod_files_desc": "Dokumendid, lepingud ja meedia", "events_mod_schedule": "Ajakava", - "events_mod_schedule_desc": "รœrituse ajakava ja programm", + "events_mod_schedule_desc": "ฤ†ยœrituse ajakava ja programm", "events_mod_budget": "Eelarve", - "events_mod_budget_desc": "Tulud, kulud ja jรคlgimine", - "events_mod_guests": "Kรผlalised", - "events_mod_guests_desc": "Kรผlaliste nimekiri ja registreerimine", + "events_mod_budget_desc": "Tulud, kulud ja jฤ†ยคlgimine", + "events_mod_guests": "Kฤ†ยผlalised", + "events_mod_guests_desc": "Kฤ†ยผlaliste nimekiri ja registreerimine", "events_mod_team": "Meeskond", "events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine", "events_mod_sponsors": "Sponsorid", "events_mod_sponsors_desc": "Sponsorid, partnerid ja kohustused", "module_coming_soon": "Tulekul", - "module_coming_soon_desc": "See moodul on arendamisel ja saab peagi kรคttesaadavaks.", - "team_title": "รœrituse meeskond", - "team_subtitle": "Halda meeskonnaliikmeid ja nende rolle selle รผrituse jaoks.", + "module_coming_soon_desc": "See moodul on arendamisel ja saab peagi kฤ†ยคttesaadavaks.", + "team_title": "ฤ†ยœrituse meeskond", + "team_subtitle": "Halda meeskonnaliikmeid ja nende rolle selle ฤ†ยผrituse jaoks.", "team_add_member": "Lisa liige", "team_role_lead": "Juht", "team_role_manager": "Haldur", "team_role_member": "Liige", - "team_empty": "Meeskonnaliikmeid pole veel mรครคratud. Lisa liikmeid oma organisatsioonist.", - "team_remove_confirm": "Eemalda {name} selle รผrituse meeskonnast?", + "team_empty": "Meeskonnaliikmeid pole veel mฤ†ยคฤ†ยคratud. Lisa liikmeid oma organisatsioonist.", + "team_remove_confirm": "Eemalda {name} selle ฤ†ยผrituse meeskonnast?", "team_remove_btn": "Eemalda", "team_added": "{name} lisatud meeskonda", "team_removed": "{name} eemaldatud meeskonnast", @@ -349,66 +372,358 @@ "team_select_member": "Vali liige", "team_select_role": "Vali roll", "team_already_assigned": "Juba meeskonnas", - "team_departments": "Osakonnad", + "team_departments": "Valdkonnad", "team_roles": "Rollid", - "team_all": "Kรตik", - "team_no_department": "Mรครคramata", - "team_add_department": "Lisa osakond", + "team_all": "Kฤ†ยตik", + "team_no_department": "Mฤ†ยคฤ†ยคramata", + "team_add_department": "Lisa valdkond", "team_add_role": "Lisa roll", - "team_edit_department": "Muuda osakonda", + "team_edit_department": "Muuda valdkonda", "team_edit_role": "Muuda rolli", - "team_dept_name": "Osakonna nimi", + "team_dept_name": "Valdkonna nimi", "team_role_name": "Rolli nimi", - "team_dept_created": "Osakond loodud", - "team_dept_updated": "Osakond uuendatud", - "team_dept_deleted": "Osakond kustutatud", + "team_dept_created": "Valdkond loodud", + "team_dept_updated": "Valdkond uuendatud", + "team_dept_deleted": "Valdkond kustutatud", "team_role_created": "Roll loodud", "team_role_updated": "Roll uuendatud", "team_role_deleted": "Roll kustutatud", - "team_dept_delete_confirm": "Kustuta osakond {name}? Liikmed eemaldatakse sellest.", + "team_dept_delete_confirm": "Kustuta valdkond {name}? Liikmed eemaldatakse sellest.", "team_role_delete_confirm": "Kustuta roll {name}? Liikmed kaotavad selle rolli.", - "team_view_by_dept": "Osakondade jรคrgi", + "team_view_by_dept": "Valdkondade jฤ†ยคrgi", "team_view_list": "Nimekirja vaade", "team_member_count": "{count} liiget", - "team_assign_dept": "Mรครคra osakonnad", - "team_notes": "Mรคrkmed", - "team_notes_placeholder": "Valikulised mรคrkmed selle liikme kohta...", - "overview_subtitle": "Tere tagasi. Siin on รผlevaade toimuvast.", - "overview_stat_events": "รœritused", - "overview_upcoming_events": "Tulevased รผritused", - "overview_upcoming_empty": "Tulevasi รผritusi pole. Loo รผks alustamiseks.", - "overview_view_all_events": "Vaata kรตiki รผritusi", + "team_assign_dept": "Mฤ†ยคฤ†ยคra valdkonnad", + "team_notes": "Mฤ†ยคrkmed", + "team_notes_placeholder": "Valikulised mฤ†ยคrkmed selle liikme kohta...", + "overview_subtitle": "Tere tagasi. Siin on ฤ†ยผlevaade toimuvast.", + "overview_stat_events": "ฤ†ยœritused", + "overview_upcoming_events": "Tulevased ฤ†ยผritused", + "overview_upcoming_empty": "Tulevasi ฤ†ยผritusi pole. Loo ฤ†ยผks alustamiseks.", + "overview_view_all_events": "Vaata kฤ†ยตiki ฤ†ยผritusi", "overview_more_members": "+{count} veel", "chat_join_title": "Liitu vestlusega", - "chat_join_description": "Vestlus pรตhineb Matrixil โ€” avatud standardil turvalise ja detsentraliseeritud suhtluse jaoks.", - "chat_join_consent": "Liitudes luuakse sulle Matrixi konto sinu praeguste profiiliandmete (nimi, e-post ja avatar) pรตhjal.", - "chat_join_learn_more": "Loe Matrixi kohta lรคhemalt", + "chat_join_description": "Vestlus pฤ†ยตhineb Matrixil - avatud standardil turvalise ja detsentraliseeritud suhtluse jaoks.", + "chat_join_consent": "Liitudes luuakse sulle Matrixi konto sinu praeguste profiiliandmete (nimi, e-post ja avatar) pฤ†ยตhjal.", + "chat_join_learn_more": "Loe Matrixi kohta lฤ†ยคhemalt", "chat_join_button": "Liitu vestlusega", "chat_joining": "Konto seadistamine...", "chat_join_success": "Vestluskonto loodud! Tere tulemast.", - "chat_join_error": "Vestluse seadistamine ebaรตnnestus. Proovi uuesti.", - "chat_disconnect": "Katkesta vestlusรผhendus", + "chat_join_error": "Vestluse seadistamine ebaฤ†ยตnnestus. Proovi uuesti.", + "chat_disconnect": "Katkesta vestlusฤ†ยผhendus", "dept_dashboard_no_modules": "Mooduleid pole veel seadistatud", "dept_dashboard_add_first": "Lisa oma esimene moodul", "dept_dashboard_add_module": "Lisa moodul", - "dept_dashboard_all_added": "Kรตik moodulid on juba lisatud", + "dept_dashboard_all_added": "Kฤ†ยตik moodulid on juba lisatud", "dept_dashboard_expand": "Laienda", "dept_dashboard_remove_module": "Eemalda moodul", "dept_dashboard_coming_soon": "Tulekul", "dept_dashboard_module_coming_soon": "Moodul tulekul", - "dept_dashboard_departments": "Osakonnad", + "dept_dashboard_departments": "Valdkonnad", "dept_checklist_no_items": "Kontrollnimekirju pole veel", "dept_checklist_add": "Lisa kontrollnimekiri", - "dept_checklist_add_item": "Lisa รผksus...", - "dept_notes_no_notes": "Mรคrkmeid pole veel", - "dept_notes_new": "Uus mรคrge", - "dept_notes_select": "Vali mรคrge", + "dept_checklist_add_item": "Lisa ฤ†ยผksus...", + "dept_notes_no_notes": "Mฤ†ยคrkmeid pole veel", + "dept_notes_new": "Uus mฤ†ยคrge", + "dept_notes_select": "Vali mฤ†ยคrge", "dept_notes_placeholder": "Alusta kirjutamist...", - "dept_notes_title_placeholder": "Mรคrkme pealkiri...", - "dept_kanban_open": "Ava รผlesannete tahvel", - "dept_kanban_desc": "Selle osakonna รผlesannete tahvel", + "dept_notes_title_placeholder": "Mฤ†ยคrkme pealkiri...", + "dept_kanban_open": "Ava ฤ†ยผlesannete tahvel", + "dept_kanban_desc": "Selle valdkonna ฤ†ยผlesannete tahvel", "dept_files_open": "Ava failid", - "dept_files_desc": "Osakonna failid ja dokumendid", + "dept_files_desc": "Valdkonna failid ja dokumendid", "dept_quick_add": "Kiirvalik", - "dept_modules_label": "Moodulid" + "dept_modules_label": "Moodulid", + "dept_dashboard_collapse": "Ahenda", + "dept_dashboard_move_left": "Liiguta vasakule", + "dept_dashboard_move_right": "Liiguta paremale", + "dept_dashboard_layout_1col": "1 veerg", + "dept_dashboard_layout_2col": "2 veergu", + "dept_dashboard_layout_3col": "3 veergu", + "dept_dashboard_layout_grid": "2ฤ†โ€”2 ruudustik", + "dept_module_kanban": "Kanban", + "dept_module_files": "Failid", + "dept_module_checklist": "Kontrollnimekiri", + "dept_module_notes": "Mฤ†ยคrkmed", + "dept_module_schedule": "Ajakava", + "dept_module_contacts": "Kontaktid", + "dept_module_budget": "Eelarve", + "dept_module_sponsors": "Sponsorid", + "dept_module_map": "Kaart", + "toast_error_update_layout": "Paigutuse uuendamine ebaฤ†ยตnnestus", + "toast_error_add_module": "Mooduli lisamine ebaฤ†ยตnnestus", + "toast_error_remove_module": "Mooduli eemaldamine ebaฤ†ยตnnestus", + "toast_error_reorder_modules": "Moodulite ฤ†ยผmberjฤ†ยคrjestamine ebaฤ†ยตnnestus", + "toast_error_add_item": "ฤ†ยœksuse lisamine ebaฤ†ยตnnestus", + "toast_error_update_item": "ฤ†ยœksuse uuendamine ebaฤ†ยตnnestus", + "toast_error_delete_item": "ฤ†ยœksuse kustutamine ebaฤ†ยตnnestus", + "toast_error_create_checklist": "Kontrollnimekirja loomine ebaฤ†ยตnnestus", + "toast_error_delete_checklist": "Kontrollnimekirja kustutamine ebaฤ†ยตnnestus", + "toast_error_rename_checklist": "Kontrollnimekirja ฤ†ยผmbernimetamine ebaฤ†ยตnnestus", + "toast_error_create_note": "Mฤ†ยคrkme loomine ebaฤ†ยตnnestus", + "toast_error_update_note": "Mฤ†ยคrkme uuendamine ebaฤ†ยตnnestus", + "toast_error_delete_note": "Mฤ†ยคrkme kustutamine ebaฤ†ยตnnestus", + "toast_error_create_stage": "Lava loomine ebaฤ†ยตnnestus", + "toast_error_delete_stage": "Lava kustutamine ebaฤ†ยตnnestus", + "toast_error_create_block": "Ajakavaploki loomine ebaฤ†ยตnnestus", + "toast_error_update_block": "Ajakavaploki uuendamine ebaฤ†ยตnnestus", + "toast_error_delete_block": "Ajakavaploki kustutamine ebaฤ†ยตnnestus", + "toast_error_create_contact": "Kontakti loomine ebaฤ†ยตnnestus", + "toast_error_update_contact": "Kontakti uuendamine ebaฤ†ยตnnestus", + "toast_error_delete_contact": "Kontakti kustutamine ebaฤ†ยตnnestus", + "toast_error_create_category": "Kategooria loomine ebaฤ†ยตnnestus", + "toast_error_delete_category": "Kategooria kustutamine ebaฤ†ยตnnestus", + "toast_error_create_budget_item": "Eelarveฤ†ยผksuse loomine ebaฤ†ยตnnestus", + "toast_error_update_budget_item": "Eelarveฤ†ยผksuse uuendamine ebaฤ†ยตnnestus", + "toast_error_delete_budget_item": "Eelarveฤ†ยผksuse kustutamine ebaฤ†ยตnnestus", + "toast_error_upload_receipt": "Kviitungi ฤ†ยผleslaadimine ebaฤ†ยตnnestus", + "toast_success_receipt_attached": "Kviitung \"{name}\" lisatud", + "toast_error_create_tier": "Taseme loomine ebaฤ†ยตnnestus", + "toast_error_delete_tier": "Taseme kustutamine ebaฤ†ยตnnestus", + "toast_error_create_sponsor": "Sponsori loomine ebaฤ†ยตnnestus", + "toast_error_update_sponsor": "Sponsori uuendamine ebaฤ†ยตnnestus", + "toast_error_delete_sponsor": "Sponsori kustutamine ebaฤ†ยตnnestus", + "toast_error_create_deliverable": "Kohustuse loomine ebaฤ†ยตnnestus", + "toast_error_update_deliverable": "Kohustuse uuendamine ebaฤ†ยตnnestus", + "toast_error_delete_deliverable": "Kohustuse kustutamine ebaฤ†ยตnnestus", + "checklist_rename": "Nimeta ฤ†ยผmber", + "checklist_delete": "Kustuta nimekiri", + "checklist_add_item_placeholder": "Lisa ฤ†ยผksus...", + "checklist_name_placeholder": "Nimekirja nimi...", + "checklist_no_items": "Kontrollnimekirju pole veel", + "checklist_add": "Lisa kontrollnimekiri", + "notes_new": "Uus mฤ†ยคrge", + "notes_title_placeholder": "Mฤ†ยคrkme pealkiri...", + "notes_placeholder": "Alusta kirjutamist...", + "notes_no_notes": "Mฤ†ยคrkmeid pole veel", + "notes_select": "Vali mฤ†ยคrge", + "notes_export_document": "Ekspordi dokumendina", + "notes_delete": "Kustuta mฤ†ยคrge", + "notes_exported": "Eksporditud \"{title}\" dokumendina", + "notes_export_error": "Mฤ†ยคrkme eksportimine ebaฤ†ยตnnestus", + "schedule_timeline": "Ajajoon", + "schedule_list": "Nimekiri", + "schedule_add_block": "Lisa plokk", + "schedule_manage_stages": "Halda lavasid", + "schedule_no_blocks": "Ajakavaplokke pole veel", + "schedule_add_first": "Lisa oma esimene plokk ajakava koostamise alustamiseks.", + "schedule_all_day": "Terve pฤ†ยคev", + "schedule_no_stage": "Lava puudub", + "schedule_add_stage_title": "Lisa lava / ruum", + "schedule_stage_name_placeholder": "nt Peamine lava", + "schedule_add_stage": "Lisa lava", + "schedule_existing_stages": "Olemasolevad lavad", + "schedule_no_stages": "Lavasid pole veel", + "schedule_add_block_title": "Lisa ajakavaplokk", + "schedule_edit_block_title": "Muuda ajakavaplokki", + "schedule_block_title_label": "Pealkiri", + "schedule_block_title_placeholder": "nt Avamine", + "schedule_block_speaker_label": "Esineja / Juht", + "schedule_block_speaker_placeholder": "nt Jaan Tamm", + "schedule_block_start_label": "Algusaeg", + "schedule_block_end_label": "Lฤ†ยตpuaeg", + "schedule_block_stage_label": "Lava / Ruum", + "schedule_block_no_stage": "Lava puudub", + "schedule_block_description_label": "Kirjeldus", + "schedule_block_description_placeholder": "Valikuline kirjeldus...", + "schedule_block_color_label": "Vฤ†ยคrv", + "schedule_block_delete": "Kustuta plokk", + "contacts_search_placeholder": "Otsi kontakte...", + "contacts_add": "Lisa kontakt", + "contacts_no_contacts": "Kontakte pole veel", + "contacts_add_first": "Lisa oma esimene kontakt kataloogi loomiseks.", + "contacts_no_results": "Otsingutulemusi ei leitud", + "contacts_category_all": "Kฤ†ยตik", + "contacts_category_venue": "Toimumiskoht", + "contacts_category_catering": "Toitlustus", + "contacts_category_av": "AV / Tehnika", + "contacts_category_security": "Turvalisus", + "contacts_category_transport": "Transport", + "contacts_category_entertainment": "Meelelahutus", + "contacts_category_media": "Meedia", + "contacts_category_sponsor": "Sponsor", + "contacts_category_other": "Muu", + "contacts_add_title": "Lisa kontakt", + "contacts_edit_title": "Muuda kontakti", + "contacts_name_label": "Nimi", + "contacts_name_placeholder": "Kontakti nimi", + "contacts_role_label": "Roll / Ametinimetus", + "contacts_role_placeholder": "nt ฤ†ยœrituse juht", + "contacts_company_label": "Ettevฤ†ยตte", + "contacts_company_placeholder": "Ettevฤ†ยตtte nimi", + "contacts_email_label": "E-post", + "contacts_email_placeholder": "email@nฤ†ยคide.ee", + "contacts_phone_label": "Telefon", + "contacts_phone_placeholder": "+372 ...", + "contacts_website_label": "Veebileht", + "contacts_website_placeholder": "https://...", + "contacts_category_label": "Kategooria", + "contacts_notes_label": "Mฤ†ยคrkmed", + "contacts_notes_placeholder": "Valikulised mฤ†ยคrkmed...", + "contacts_delete_confirm": "Kustuta see kontakt?", + "budget_income": "Tulud", + "budget_expenses": "Kulud", + "budget_planned": "Planeeritud: {amount}", + "budget_planned_balance": "Planeeritud saldo", + "budget_actual_balance": "Tegelik saldo", + "budget_view_all": "Kฤ†ยตik", + "budget_view_income": "Tulud", + "budget_view_expenses": "Kulud", + "budget_add_category": "Kategooria", + "budget_add_item": "Lisa ฤ†ยผksus", + "budget_no_items": "Eelarveฤ†ยผksusi pole veel", + "budget_col_type": "Tฤ†ยผฤ†ยผp", + "budget_col_description": "Kirjeldus", + "budget_col_category": "Kategooria", + "budget_col_planned": "Planeeritud", + "budget_col_actual": "Tegelik", + "budget_col_diff": "Vahe", + "budget_col_receipt": "Kviitung", + "budget_uncategorized": "Kategoriseerimata", + "budget_total": "Kokku", + "budget_missing_receipt": "Arve/kviitung puudub - kliki ฤ†ยผleslaadimiseks", + "budget_missing_receipt_short": "Arve/kviitung puudub", + "budget_receipt_attached": "Kviitung lisatud", + "budget_add_item_title": "Lisa eelarveฤ†ยผksus", + "budget_edit_item_title": "Muuda eelarveฤ†ยผksust", + "budget_description_label": "Kirjeldus", + "budget_description_placeholder": "nt Ruumi rent", + "budget_type_label": "Tฤ†ยผฤ†ยผp", + "budget_type_expense": "Kulu", + "budget_type_income": "Tulu", + "budget_category_label": "Kategooria", + "budget_planned_amount_label": "Planeeritud summa", + "budget_actual_amount_label": "Tegelik summa", + "budget_notes_label": "Mฤ†ยคrkmed", + "budget_notes_placeholder": "Valikulised mฤ†ยคrkmed...", + "budget_add_category_title": "Lisa kategooria", + "budget_category_name_label": "Nimi", + "budget_category_name_placeholder": "nt Toimumiskoht", + "budget_category_color_label": "Vฤ†ยคrv", + "budget_select_color": "Vali vฤ†ยคrv {color}", + "budget_existing_categories": "Olemasolevad kategooriad", + "sponsors_search_placeholder": "Otsi sponsoreid...", + "sponsors_add_tier": "Lisa tase", + "sponsors_add_sponsor": "Lisa sponsor", + "sponsors_no_sponsors": "Sponsoreid pole veel", + "sponsors_add_first": "Lisa sponsoritasemed ja alusta sponsorite jฤ†ยคlgimist.", + "sponsors_no_results": "Filtritele vastavaid sponsoreid ei leitud", + "sponsors_filter_all_statuses": "Kฤ†ยตik staatused", + "sponsors_filter_all_tiers": "Kฤ†ยตik tasemed", + "sponsors_status_prospect": "Potentsiaalne", + "sponsors_status_contacted": "Kontakteeritud", + "sponsors_status_confirmed": "Kinnitatud", + "sponsors_status_declined": "Keeldunud", + "sponsors_status_active": "Aktiivne", + "sponsors_contact_label": "Kontakt", + "sponsors_amount_label": "Summa", + "sponsors_deliverables_label": "Kohustused", + "sponsors_deliverable_placeholder": "Lisa kohustus...", + "sponsors_no_deliverables": "Kohustusi pole veel", + "sponsors_notes_label": "Mฤ†ยคrkmed", + "sponsors_add_tier_title": "Lisa sponsoritase", + "sponsors_tier_name_label": "Taseme nimi", + "sponsors_tier_name_placeholder": "nt Kuld", + "sponsors_tier_amount_label": "Minimaalne summa", + "sponsors_tier_color_label": "Vฤ†ยคrv", + "sponsors_existing_tiers": "Olemasolevad tasemed", + "sponsors_no_tiers": "Tasemeid pole veel", + "sponsors_add_sponsor_title": "Lisa sponsor", + "sponsors_edit_sponsor_title": "Muuda sponsorit", + "sponsors_name_label": "Sponsori nimi", + "sponsors_name_placeholder": "Ettevฤ†ยตtte nimi", + "sponsors_tier_label": "Tase", + "sponsors_no_tier": "Tase puudub", + "sponsors_status_label": "Staatus", + "sponsors_sponsor_amount_label": "Summa (ฤโ€šยฌ)", + "sponsors_sponsor_amount_placeholder": "0", + "sponsors_contact_name_label": "Kontaktisiku nimi", + "sponsors_contact_name_placeholder": "Kontaktisik", + "sponsors_contact_email_label": "Kontakti e-post", + "sponsors_contact_email_placeholder": "email@nฤ†ยคide.ee", + "sponsors_contact_phone_label": "Kontakti telefon", + "sponsors_contact_phone_placeholder": "+372 ...", + "sponsors_website_label": "Veebileht", + "sponsors_website_placeholder": "https://...", + "sponsors_notes_placeholder": "Valikulised mฤ†ยคrkmed...", + "sponsors_delete_confirm": "Kustuta see sponsor?", + "sponsors_select_color": "Vali vฤ†ยคrv {color}", + "files_widget_loading": "Failide laadimine...", + "files_widget_no_folder": "Valdkonna kausta pole veel loodud", + "files_widget_empty": "Faile pole veel", + "files_widget_full_view": "Tฤ†ยคisvaade", + "files_widget_create_title": "Loo uus", + "files_widget_type_document": "Dokument", + "files_widget_type_folder": "Kaust", + "files_widget_type_kanban": "Kanban tahvel", + "files_widget_name_label": "Nimi", + "files_widget_doc_placeholder": "Dokumendi nimi", + "files_widget_folder_placeholder": "Kausta nimi", + "files_widget_kanban_placeholder": "Tahvli nimi", + "files_widget_drop_files": "Lohista failid ฤ†ยผleslaadimiseks", + "files_widget_uploading": "ฤ†ยœleslaadimine {name}...", + "kanban_widget_loading": "Tahvlite laadimine...", + "kanban_widget_no_folder": "Valdkonna kausta pole veel loodud", + "kanban_widget_no_boards": "Kanban tahvleid pole veel", + "kanban_widget_create": "Loo tahvel", + "kanban_widget_create_title": "Loo Kanban tahvel", + "kanban_widget_name_label": "Tahvli nimi", + "kanban_widget_name_placeholder": "nt ฤ†ยœlesannete jฤ†ยคlgija", + "finances_title": "ฤ†ยœrituse rahandus", + "finances_subtitle": "Eelarve ฤ†ยผlevaade kฤ†ยตigi valdkondade lฤ†ยตikes", + "finances_total_income": "Tulud kokku", + "finances_total_expenses": "Kulud kokku", + "finances_net_balance": "Netosaldo", + "finances_missing_receipts": "Puuduvad kviitungid", + "finances_items_without_receipts": "{count} ฤ†ยผksust ilma kviitungita", + "finances_planned": "planeeritud", + "finances_view_by_dept": "Valdkondade jฤ†ยคrgi", + "finances_view_by_category": "Kategooriate jฤ†ยคrgi", + "finances_filter_all": "Kฤ†ยตik", + "finances_filter_income": "Tulud", + "finances_filter_expenses": "Kulud", + "finances_no_items": "Eelarveฤ†ยผksusi pole veel", + "finances_no_items_desc": "Eelarveฤ†ยผksused ilmuvad siia, kui valdkonnad neid lisavad.", + "finances_col_description": "Kirjeldus", + "finances_col_department": "Valdkond", + "finances_col_category": "Kategooria", + "finances_col_planned": "Planeeritud", + "finances_col_actual": "Tegelik", + "finances_col_diff": "Vahe", + "finances_col_receipt": "Kviitung", + "finances_uncategorized": "Kategoriseerimata", + "finances_no_department": "Valdkond puudub", + "finances_group_total": "Vahesumma", + "btn_creating": "Loomine...", + "page_chat": "Vestlus", + "page_department": "Valdkond", + "page_invite": "Kutse", + "map_tool_grab": "Liiguta", + "map_tool_select": "Vali", + "map_tool_pin": "Marker", + "map_tool_polygon": "Hulknurk", + "map_tool_rectangle": "Ristkรผlik", + "map_add_layer": "Lisa kaardikiht", + "map_add_image_layer": "Lisa pildikaart", + "map_layer_name": "Kihi nimi", + "map_street_map": "Tรคnavakaart", + "map_custom_image": "Kohandatud pilt", + "map_osm_tiles": "OpenStreetMap kaardikihid", + "map_upload_venue": "Laadi รผles koha kaart", + "map_choose_image": "Vali pildifail", + "map_uploading": "รœleslaadimine...", + "map_image_url": "Pildi URL", + "map_image_desc": "Laadi รผles koha plaan vรตi kaardipilt. Kuvasuhe sรคilitatakse.", + "map_objects": "Objektid", + "map_no_objects": "Objekte pole veel", + "map_export_png": "Ekspordi PNG", + "map_rename_layer": "Nimeta kiht รผmber", + "map_edit_pin": "Muuda markerit", + "map_add_pin": "Lisa marker", + "map_edit_shape": "Muuda kujundit", + "map_label": "Silt", + "map_description": "Kirjeldus", + "map_color": "Vรคrv", + "map_pen_hint_points": "{count} punkt(i)", + "map_pen_hint_close": "kliki esimese punkti lรคhedal sulgemiseks vรตi vajuta Esc tรผhistamiseks" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 36a5ad8..423dae6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "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", "@tanstack/svelte-virtual": "^3.13.18", @@ -16,8 +15,10 @@ "@tiptap/extension-placeholder": "^3.19.0", "@tiptap/pm": "^3.19.0", "@tiptap/starter-kit": "^3.19.0", + "dom-to-image-more": "^3.7.2", "google-auth-library": "^10.5.0", "highlight.js": "^11.11.1", + "leaflet": "^1.9.4", "marked": "^17.0.1", "matrix-js-sdk": "^40.2.0-rc.0", "twemoji": "^14.0.2" @@ -31,9 +32,11 @@ "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", + "@types/leaflet": "^1.9.21", "@types/marked": "^5.0.2", "@types/twemoji": "^13.1.1", "@vitest/browser-playwright": "^4.0.18", + "@vitest/coverage-v8": "^4.0.18", "playwright": "^1.58.0", "supabase": "^2.76.1", "svelte": "^5.48.2", @@ -45,6 +48,42 @@ "vitest-browser-svelte": "^2.0.2" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -54,6 +93,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -2154,6 +2217,23 @@ "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -2230,6 +2310,7 @@ "integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/mocker": "4.0.18", "@vitest/utils": "4.0.18", @@ -2272,6 +2353,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -2477,6 +2589,28 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", + "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2809,6 +2943,12 @@ "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", "license": "MIT" }, + "node_modules/dom-to-image-more": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/dom-to-image-more/-/dom-to-image-more-3.7.2.tgz", + "integrity": "sha512-uQf+pHv6eQhgfI8t2bFuinV0KsPyT8TZgCLwcSU8uBVgN9v6leb0mMpvp6HQAlAcplP3NCcGjxbdqef6pTzvmw==", + "license": "MIT" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3191,6 +3331,16 @@ "node": ">=18" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3213,6 +3363,13 @@ "node": ">=12.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -3315,6 +3472,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -3347,6 +3543,13 @@ "dev": true, "license": "MIT" }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "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", @@ -3432,6 +3635,12 @@ "node": ">=14.0.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -3742,6 +3951,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/markdown-it": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", @@ -4537,6 +4774,19 @@ "sdp-verify": "checker.js" } }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-cookie-parser": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", @@ -4751,6 +5001,19 @@ "npm": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "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", diff --git a/package.json b/package.json index 772c634..1de4ea1 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", + "@types/leaflet": "^1.9.21", "@types/marked": "^5.0.2", "@types/twemoji": "^13.1.1", "@vitest/browser-playwright": "^4.0.18", + "@vitest/coverage-v8": "^4.0.18", "playwright": "^1.58.0", "supabase": "^2.76.1", "svelte": "^5.48.2", @@ -46,10 +48,12 @@ "@tiptap/extension-placeholder": "^3.19.0", "@tiptap/pm": "^3.19.0", "@tiptap/starter-kit": "^3.19.0", + "dom-to-image-more": "^3.7.2", "google-auth-library": "^10.5.0", "highlight.js": "^11.11.1", + "leaflet": "^1.9.4", "marked": "^17.0.1", "matrix-js-sdk": "^40.2.0-rc.0", "twemoji": "^14.0.2" } -} \ No newline at end of file +} diff --git a/src/lib/api/activity.test.ts b/src/lib/api/activity.test.ts new file mode 100644 index 0000000..2af46b9 --- /dev/null +++ b/src/lib/api/activity.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from 'vitest'; +import { logActivity } from './activity'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'insert']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('logActivity', () => { + it('inserts activity log entry', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect( + logActivity(sb, { + orgId: 'org1', + userId: 'user1', + action: 'create', + entityType: 'document', + entityId: 'doc1', + entityName: 'Test Doc', + }) + ).resolves.toBeUndefined(); + expect(sb.from).toHaveBeenCalledWith('activity_log'); + }); + + it('does not throw on error (fire-and-forget)', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + // logActivity should warn but not throw + await expect( + logActivity(sb, { + orgId: 'org1', + userId: 'user1', + action: 'delete', + entityType: 'folder', + }) + ).resolves.toBeUndefined(); + }); + + it('passes metadata as JSON', async () => { + const sb = mockSupabase({ data: null, error: null }); + await logActivity(sb, { + orgId: 'org1', + userId: 'user1', + action: 'move', + entityType: 'kanban_card', + metadata: { from: 'col1', to: 'col2' }, + }); + expect(sb.from).toHaveBeenCalledWith('activity_log'); + }); +}); diff --git a/src/lib/api/activity.ts b/src/lib/api/activity.ts index 0f46ac2..c3f9a90 100644 --- a/src/lib/api/activity.ts +++ b/src/lib/api/activity.ts @@ -32,7 +32,7 @@ export async function logActivity( }); if (error) { - // Activity logging should never block the main action โ€” just warn + // 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/budget.test.ts b/src/lib/api/budget.test.ts new file mode 100644 index 0000000..e0588f0 --- /dev/null +++ b/src/lib/api/budget.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchBudgetCategories, + createBudgetCategory, + updateBudgetCategory, + deleteBudgetCategory, + fetchEventBudgetCategories, + fetchEventBudgetItems, + fetchBudgetItems, + createBudgetItem, + updateBudgetItem, + deleteBudgetItem, +} from './budget'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single', 'in']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ Budget Categories โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchBudgetCategories', () => { + it('returns categories for a department', async () => { + const cats = [{ id: 'c1', name: 'Travel', department_id: 'd1' }]; + const sb = mockSupabase({ data: cats, error: null }); + const result = await fetchBudgetCategories(sb, 'd1'); + expect(result).toEqual(cats); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchBudgetCategories(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createBudgetCategory', () => { + it('creates a category with default color', async () => { + const cat = { id: 'c1', name: 'Food', department_id: 'd1', color: '#6366f1' }; + const sb = mockSupabase({ data: cat, error: null }); + const result = await createBudgetCategory(sb, 'd1', 'Food'); + expect(result).toEqual(cat); + }); + + it('creates a category with custom color', async () => { + const cat = { id: 'c2', name: 'AV', department_id: 'd1', color: '#ff0000' }; + const sb = mockSupabase({ data: cat, error: null }); + const result = await createBudgetCategory(sb, 'd1', 'AV', '#ff0000'); + expect(result).toEqual(cat); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'dup' } }); + await expect(createBudgetCategory(sb, 'd1', 'X')).rejects.toEqual({ message: 'dup' }); + }); +}); + +describe('updateBudgetCategory', () => { + it('updates and returns the category', async () => { + const cat = { id: 'c1', name: 'Updated' }; + const sb = mockSupabase({ data: cat, error: null }); + const result = await updateBudgetCategory(sb, 'c1', { name: 'Updated' }); + expect(result).toEqual(cat); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'nope' } }); + await expect(updateBudgetCategory(sb, 'c1', { name: 'X' })).rejects.toEqual({ message: 'nope' }); + }); +}); + +describe('deleteBudgetCategory', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteBudgetCategory(sb, 'c1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'del fail' } }); + await expect(deleteBudgetCategory(sb, 'c1')).rejects.toEqual({ message: 'del fail' }); + }); +}); + +describe('fetchEventBudgetCategories', () => { + it('returns categories stripped of join data', async () => { + const raw = [{ id: 'c1', name: 'Cat', event_departments: { event_id: 'e1' } }]; + const sb = mockSupabase({ data: raw, error: null }); + const result = await fetchEventBudgetCategories(sb, 'e1'); + expect(result).toEqual([{ id: 'c1', name: 'Cat' }]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchEventBudgetCategories(sb, 'e1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('fetchEventBudgetItems', () => { + it('returns items stripped of join data', async () => { + const raw = [{ id: 'i1', description: 'Item', event_departments: { event_id: 'e1' } }]; + const sb = mockSupabase({ data: raw, error: null }); + const result = await fetchEventBudgetItems(sb, 'e1'); + expect(result).toEqual([{ id: 'i1', description: 'Item' }]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchEventBudgetItems(sb, 'e1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Budget Items โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchBudgetItems', () => { + it('returns items for a department', async () => { + const items = [{ id: 'i1', description: 'Mic', department_id: 'd1' }]; + const sb = mockSupabase({ data: items, error: null }); + const result = await fetchBudgetItems(sb, 'd1'); + expect(result).toEqual(items); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchBudgetItems(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createBudgetItem', () => { + it('creates an expense item with defaults', async () => { + const item = { id: 'i1', description: 'Mic', item_type: 'expense', planned_amount: 0, actual_amount: 0 }; + const sb = mockSupabase({ data: item, error: null }); + const result = await createBudgetItem(sb, 'd1', { description: 'Mic', item_type: 'expense' }); + expect(result).toEqual(item); + }); + + it('creates an income item with amounts', async () => { + const item = { id: 'i2', description: 'Ticket', item_type: 'income', planned_amount: 100, actual_amount: 50 }; + const sb = mockSupabase({ data: item, error: null }); + const result = await createBudgetItem(sb, 'd1', { + description: 'Ticket', + item_type: 'income', + planned_amount: 100, + actual_amount: 50, + }); + expect(result).toEqual(item); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createBudgetItem(sb, 'd1', { description: 'X', item_type: 'expense' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateBudgetItem', () => { + it('updates and returns the item', async () => { + const item = { id: 'i1', description: 'Updated' }; + const sb = mockSupabase({ data: item, error: null }); + const result = await updateBudgetItem(sb, 'i1', { description: 'Updated' }); + expect(result).toEqual(item); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateBudgetItem(sb, 'i1', { description: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteBudgetItem', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteBudgetItem(sb, 'i1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteBudgetItem(sb, 'i1')).rejects.toEqual({ message: 'fail' }); + }); +}); diff --git a/src/lib/api/budget.ts b/src/lib/api/budget.ts index b1897a9..3fe1777 100644 --- a/src/lib/api/budget.ts +++ b/src/lib/api/budget.ts @@ -87,6 +87,52 @@ export async function deleteBudgetCategory( } } +/** + * Fetch all budget categories for all departments in an event. + */ +export async function fetchEventBudgetCategories( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data, error } = await db(supabase) + .from('budget_categories') + .select('*, event_departments!inner(event_id)') + .eq('event_departments.event_id', eventId) + .order('sort_order'); + + if (error) { + log.error('fetchEventBudgetCategories failed', { error, data: { eventId } }); + throw error; + } + return (data ?? []).map((d: any) => { + const { event_departments, ...cat } = d; + return cat; + }) as BudgetCategory[]; +} + +/** + * Fetch all budget items for all departments in an event. + */ +export async function fetchEventBudgetItems( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data, error } = await db(supabase) + .from('budget_items') + .select('*, event_departments!inner(event_id)') + .eq('event_departments.event_id', eventId) + .order('sort_order'); + + if (error) { + log.error('fetchEventBudgetItems failed', { error, data: { eventId } }); + throw error; + } + return (data ?? []).map((d: any) => { + const { event_departments, ...item } = d; + return item; + }) as BudgetItem[]; +} + // ============================================================ // Budget Items // ============================================================ @@ -144,7 +190,7 @@ export async function createBudgetItem( export async function updateBudgetItem( supabase: SupabaseClient, itemId: string, - params: Partial> + params: Partial> ): Promise { const { data, error } = await db(supabase) .from('budget_items') diff --git a/src/lib/api/calendar.test.ts b/src/lib/api/calendar.test.ts index 30f2b83..3985ab0 100644 --- a/src/lib/api/calendar.test.ts +++ b/src/lib/api/calendar.test.ts @@ -1,5 +1,90 @@ -import { describe, it, expect } from 'vitest'; -import { getMonthDays, isSameDay, formatTime } from './calendar'; +import { describe, it, expect, vi } from 'vitest'; +import { getMonthDays, isSameDay, formatTime, fetchEvents, createEvent, updateEvent, deleteEvent } from './calendar'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'gte', 'lte', 'order', 'single', 'channel', 'on', 'subscribe']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ Calendar CRUD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('calendar fetchEvents', () => { + it('returns events for date range', async () => { + const events = [{ id: 'ce1', title: 'Meeting', org_id: 'o1' }]; + const sb = mockSupabase({ data: events, error: null }); + const result = await fetchEvents(sb, 'o1', new Date('2024-01-01'), new Date('2024-01-31')); + expect(result).toEqual(events); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchEvents(sb, 'o1', new Date(), new Date())).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchEvents(sb, 'o1', new Date(), new Date())).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('calendar createEvent', () => { + it('creates and returns event', async () => { + const event = { id: 'ce1', title: 'New Meeting' }; + const sb = mockSupabase({ data: event, error: null }); + const result = await createEvent(sb, 'o1', { + title: 'New Meeting', + start_time: '2024-01-15T10:00:00Z', + end_time: '2024-01-15T11:00:00Z', + }, 'user1'); + expect(result).toEqual(event); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createEvent(sb, 'o1', { + title: 'X', + start_time: '2024-01-15T10:00:00Z', + end_time: '2024-01-15T11:00:00Z', + }, 'user1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('calendar updateEvent', () => { + it('updates without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(updateEvent(sb, 'ce1', { title: 'Updated' })).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateEvent(sb, 'ce1', { title: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('calendar deleteEvent', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteEvent(sb, 'ce1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteEvent(sb, 'ce1')).rejects.toEqual({ message: 'fail' }); + }); +}); describe('getMonthDays', () => { it('returns exactly 42 days (6 weeks grid)', () => { diff --git a/src/lib/api/contacts.test.ts b/src/lib/api/contacts.test.ts new file mode 100644 index 0000000..a1834bc --- /dev/null +++ b/src/lib/api/contacts.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchContacts, + createContact, + updateContact, + deleteContact, + CONTACT_CATEGORIES, + CATEGORY_LABELS, + CATEGORY_ICONS, +} from './contacts'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('contacts constants', () => { + it('CONTACT_CATEGORIES has expected entries', () => { + expect(CONTACT_CATEGORIES).toContain('general'); + expect(CONTACT_CATEGORIES).toContain('vendor'); + expect(CONTACT_CATEGORIES).toContain('speaker'); + expect(CONTACT_CATEGORIES).toContain('media'); + expect(CONTACT_CATEGORIES.length).toBe(10); + }); + + it('CATEGORY_LABELS has a label for every category', () => { + for (const cat of CONTACT_CATEGORIES) { + expect(CATEGORY_LABELS[cat]).toBeDefined(); + expect(typeof CATEGORY_LABELS[cat]).toBe('string'); + } + }); + + it('CATEGORY_ICONS has an icon for every category', () => { + for (const cat of CONTACT_CATEGORIES) { + expect(CATEGORY_ICONS[cat]).toBeDefined(); + } + }); +}); + +// โ”€โ”€ CRUD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchContacts', () => { + it('returns contacts for a department', async () => { + const contacts = [{ id: 'ct1', name: 'Alice', department_id: 'd1' }]; + const sb = mockSupabase({ data: contacts, error: null }); + const result = await fetchContacts(sb, 'd1'); + expect(result).toEqual(contacts); + }); + + it('returns empty array when data is null', async () => { + const sb = mockSupabase({ data: null, error: null }); + const result = await fetchContacts(sb, 'd1'); + expect(result).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchContacts(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createContact', () => { + it('creates a contact with minimal params', async () => { + const contact = { id: 'ct1', name: 'Bob', department_id: 'd1', category: 'general' }; + const sb = mockSupabase({ data: contact, error: null }); + const result = await createContact(sb, 'd1', { name: 'Bob' }); + expect(result).toEqual(contact); + }); + + it('creates a contact with all params', async () => { + const contact = { id: 'ct2', name: 'Eve', department_id: 'd1', category: 'vendor', email: 'eve@test.com' }; + const sb = mockSupabase({ data: contact, error: null }); + const result = await createContact(sb, 'd1', { + name: 'Eve', + category: 'vendor', + email: 'eve@test.com', + phone: '+1234', + company: 'Acme', + role: 'Manager', + website: 'https://acme.com', + notes: 'VIP', + }, 'user1'); + expect(result).toEqual(contact); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createContact(sb, 'd1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateContact', () => { + it('updates and returns the contact', async () => { + const contact = { id: 'ct1', name: 'Updated' }; + const sb = mockSupabase({ data: contact, error: null }); + const result = await updateContact(sb, 'ct1', { name: 'Updated' }); + expect(result).toEqual(contact); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateContact(sb, 'ct1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteContact', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteContact(sb, 'ct1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteContact(sb, 'ct1')).rejects.toEqual({ message: 'fail' }); + }); +}); diff --git a/src/lib/api/department-dashboard.test.ts b/src/lib/api/department-dashboard.test.ts new file mode 100644 index 0000000..dfd8135 --- /dev/null +++ b/src/lib/api/department-dashboard.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchDashboard, + updateDashboardLayout, + addPanel, + updatePanel, + removePanel, + fetchChecklists, + createChecklist, + deleteChecklist, + renameChecklist, + addChecklistItem, + updateChecklistItem, + deleteChecklistItem, + toggleChecklistItem, + fetchNotes, + createNote, + updateNote, + deleteNote, +} from './department-dashboard'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchDashboard', () => { + it('returns dashboard with sorted panels', async () => { + const dash = { + id: 'd1', + department_id: 'dept1', + panels: [ + { id: 'p2', position: 1 }, + { id: 'p1', position: 0 }, + ], + }; + const sb = mockSupabase({ data: dash, error: null }); + const result = await fetchDashboard(sb, 'dept1'); + expect(result).not.toBeNull(); + expect(result!.panels[0].id).toBe('p1'); + expect(result!.panels[1].id).toBe('p2'); + }); + + it('returns null when not found (PGRST116)', async () => { + const sb = mockSupabase({ data: null, error: { code: 'PGRST116', message: 'not found' } }); + const result = await fetchDashboard(sb, 'dept1'); + expect(result).toBeNull(); + }); + + it('throws on other errors', async () => { + const sb = mockSupabase({ data: null, error: { code: '42000', message: 'fail' } }); + await expect(fetchDashboard(sb, 'dept1')).rejects.toEqual({ code: '42000', message: 'fail' }); + }); +}); + +describe('updateDashboardLayout', () => { + it('updates and returns dashboard', async () => { + const dash = { id: 'd1', layout: 'grid' }; + const sb = mockSupabase({ data: dash, error: null }); + expect(await updateDashboardLayout(sb, 'd1', 'grid' as any)).toEqual(dash); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateDashboardLayout(sb, 'd1', 'grid' as any)).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Panels โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('addPanel', () => { + it('adds and returns panel', async () => { + const panel = { id: 'p1', dashboard_id: 'd1', module: 'checklist', position: 0 }; + const sb = mockSupabase({ data: panel, error: null }); + expect(await addPanel(sb, 'd1', 'checklist' as any, 0)).toEqual(panel); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(addPanel(sb, 'd1', 'checklist' as any, 0)).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updatePanel', () => { + it('updates and returns panel', async () => { + const panel = { id: 'p1', width: 'full' }; + const sb = mockSupabase({ data: panel, error: null }); + expect(await updatePanel(sb, 'p1', { width: 'full' } as any)).toEqual(panel); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updatePanel(sb, 'p1', { width: 'full' } as any)).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('removePanel', () => { + it('removes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(removePanel(sb, 'p1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(removePanel(sb, 'p1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Checklists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchChecklists', () => { + it('returns checklists with items grouped', async () => { + const checklists = [{ id: 'cl1', department_id: 'dept1', title: 'Pre-event' }]; + const items = [{ id: 'i1', checklist_id: 'cl1', content: 'Book venue', is_completed: false }]; + + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.then = (resolve: any) => { + callIdx++; + if (callIdx === 1) return resolve({ data: checklists, error: null }); + return resolve({ data: items, error: null }); + }; + const sb = { from: vi.fn(() => chain) } as any; + + const result = await fetchChecklists(sb, 'dept1'); + expect(result).toHaveLength(1); + expect(result[0].items).toHaveLength(1); + expect(result[0].items[0].content).toBe('Book venue'); + }); + + it('returns empty array when no checklists', async () => { + const sb = mockSupabase({ data: [], error: null }); + const result = await fetchChecklists(sb, 'dept1'); + expect(result).toEqual([]); + }); + + it('throws on error', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fail' } }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(fetchChecklists(sb, 'dept1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createChecklist', () => { + it('creates and returns checklist', async () => { + const cl = { id: 'cl1', title: 'Setup', department_id: 'dept1' }; + const sb = mockSupabase({ data: cl, error: null }); + expect(await createChecklist(sb, 'dept1', 'Setup', 'user1')).toEqual(cl); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createChecklist(sb, 'dept1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteChecklist', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteChecklist(sb, 'cl1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteChecklist(sb, 'cl1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('renameChecklist', () => { + it('renames and returns checklist', async () => { + const cl = { id: 'cl1', title: 'Renamed' }; + const sb = mockSupabase({ data: cl, error: null }); + expect(await renameChecklist(sb, 'cl1', 'Renamed')).toEqual(cl); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(renameChecklist(sb, 'cl1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Checklist Items โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('addChecklistItem', () => { + it('adds and returns item', async () => { + const item = { id: 'i1', checklist_id: 'cl1', content: 'Task', sort_order: 0 }; + const sb = mockSupabase({ data: item, error: null }); + expect(await addChecklistItem(sb, 'cl1', 'Task')).toEqual(item); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(addChecklistItem(sb, 'cl1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateChecklistItem', () => { + it('updates and returns item', async () => { + const item = { id: 'i1', content: 'Updated' }; + const sb = mockSupabase({ data: item, error: null }); + expect(await updateChecklistItem(sb, 'i1', { content: 'Updated' })).toEqual(item); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateChecklistItem(sb, 'i1', { content: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteChecklistItem', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteChecklistItem(sb, 'i1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteChecklistItem(sb, 'i1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('toggleChecklistItem', () => { + it('toggles completion via updateChecklistItem', async () => { + const item = { id: 'i1', is_completed: true }; + const sb = mockSupabase({ data: item, error: null }); + expect(await toggleChecklistItem(sb, 'i1', true)).toEqual(item); + }); +}); + +// โ”€โ”€ Notes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchNotes', () => { + it('returns notes for a department', async () => { + const notes = [{ id: 'n1', title: 'Meeting Notes', department_id: 'dept1' }]; + const sb = mockSupabase({ data: notes, error: null }); + expect(await fetchNotes(sb, 'dept1')).toEqual(notes); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchNotes(sb, 'dept1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchNotes(sb, 'dept1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createNote', () => { + it('creates and returns note', async () => { + const note = { id: 'n1', title: 'New Note', department_id: 'dept1' }; + const sb = mockSupabase({ data: note, error: null }); + expect(await createNote(sb, 'dept1', 'New Note', 'user1')).toEqual(note); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createNote(sb, 'dept1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateNote', () => { + it('updates and returns note', async () => { + const note = { id: 'n1', title: 'Updated' }; + const sb = mockSupabase({ data: note, error: null }); + expect(await updateNote(sb, 'n1', { title: 'Updated' })).toEqual(note); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateNote(sb, 'n1', { title: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteNote', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteNote(sb, 'n1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteNote(sb, 'n1')).rejects.toEqual({ message: 'fail' }); + }); +}); diff --git a/src/lib/api/document-locks.test.ts b/src/lib/api/document-locks.test.ts new file mode 100644 index 0000000..2b7dfbb --- /dev/null +++ b/src/lib/api/document-locks.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + getLockInfo, + acquireLock, + heartbeatLock, + releaseLock, + startHeartbeat, +} from './document-locks'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'gt', 'lt', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ getLockInfo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('getLockInfo', () => { + it('returns unlocked when no lock exists', async () => { + const sb = mockSupabase({ data: null, error: null }); + const info = await getLockInfo(sb, 'doc1', 'user1'); + expect(info).toEqual({ + isLocked: false, + lockedBy: null, + lockedByName: null, + isOwnLock: false, + }); + }); + + it('returns locked with own lock', async () => { + // First call returns lock, second returns profile + const lockData = { id: 'l1', document_id: 'doc1', user_id: 'user1', locked_at: new Date().toISOString(), last_heartbeat: new Date().toISOString() }; + const profileData = { full_name: 'Alice', email: 'alice@test.com' }; + + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'gt', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callCount = 0; + chain.single = vi.fn(() => { + callCount++; + if (callCount === 1) return Promise.resolve({ data: lockData, error: null }); + return Promise.resolve({ data: profileData, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const info = await getLockInfo(sb, 'doc1', 'user1'); + expect(info.isLocked).toBe(true); + expect(info.isOwnLock).toBe(true); + expect(info.lockedByName).toBe('Alice'); + }); + + it('returns locked with other user lock', async () => { + const lockData = { id: 'l1', document_id: 'doc1', user_id: 'user2', locked_at: new Date().toISOString(), last_heartbeat: new Date().toISOString() }; + const profileData = { full_name: 'Bob', email: 'bob@test.com' }; + + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'gt', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callCount = 0; + chain.single = vi.fn(() => { + callCount++; + if (callCount === 1) return Promise.resolve({ data: lockData, error: null }); + return Promise.resolve({ data: profileData, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const info = await getLockInfo(sb, 'doc1', 'user1'); + expect(info.isLocked).toBe(true); + expect(info.isOwnLock).toBe(false); + expect(info.lockedByName).toBe('Bob'); + }); +}); + +// โ”€โ”€ acquireLock โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('acquireLock', () => { + it('returns true when lock acquired', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await acquireLock(sb, 'doc1', 'user1')).toBe(true); + }); + + it('returns false on unique constraint violation', async () => { + const sb = mockSupabase({ data: null, error: { code: '23505', message: 'unique' } }); + expect(await acquireLock(sb, 'doc1', 'user1')).toBe(false); + }); + + it('returns false on other errors', async () => { + const sb = mockSupabase({ data: null, error: { code: '42000', message: 'other' } }); + expect(await acquireLock(sb, 'doc1', 'user1')).toBe(false); + }); +}); + +// โ”€โ”€ heartbeatLock โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('heartbeatLock', () => { + it('returns true on success', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await heartbeatLock(sb, 'doc1', 'user1')).toBe(true); + }); + + it('returns false on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + expect(await heartbeatLock(sb, 'doc1', 'user1')).toBe(false); + }); +}); + +// โ”€โ”€ releaseLock โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('releaseLock', () => { + it('releases without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(releaseLock(sb, 'doc1', 'user1')).resolves.toBeUndefined(); + }); + + it('does not throw on error (just logs)', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(releaseLock(sb, 'doc1', 'user1')).resolves.toBeUndefined(); + }); +}); + +// โ”€โ”€ startHeartbeat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('startHeartbeat', () => { + it('returns a cleanup function', () => { + const sb = mockSupabase({ data: null, error: null }); + const cleanup = startHeartbeat(sb, 'doc1', 'user1'); + expect(typeof cleanup).toBe('function'); + cleanup(); // should not throw + }); +}); diff --git a/src/lib/api/document-locks.ts b/src/lib/api/document-locks.ts index 5c78b7c..25a202a 100644 --- a/src/lib/api/document-locks.ts +++ b/src/lib/api/document-locks.ts @@ -36,7 +36,7 @@ export async function getLockInfo( return { isLocked: false, lockedBy: null, lockedByName: null, isOwnLock: false }; } - // Fetch profile separately โ€” document_locks.user_id FK points to auth.users, not profiles + // 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 @@ -87,7 +87,7 @@ export async function acquireLock( if (error) { if (error.code === '23505') { - // Unique constraint violation โ€” someone else holds the lock + // Unique constraint violation - someone else holds the lock log.debug('Lock already held', { data: { documentId } }); return false; } diff --git a/src/lib/api/documents.test.ts b/src/lib/api/documents.test.ts index 49ec815..5a2084e 100644 --- a/src/lib/api/documents.test.ts +++ b/src/lib/api/documents.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { createDocument, updateDocument, deleteDocument, moveDocument, copyDocument, fetchDocuments } from './documents'; +import { createDocument, updateDocument, deleteDocument, moveDocument, copyDocument, fetchDocuments, fetchFolderContents, findDepartmentFolder, findFinanceFolder, ensureEventsFolder, createEventFolder, createDepartmentFolder, ensureFinanceFolder, ensureFinanceDeptFolder, uploadFile, deleteFileFromStorage, subscribeToDocuments, getFileMetadata, formatFileSize } from './documents'; // Lightweight Supabase mock builder function mockSupabase(response: { data?: unknown; error?: unknown }) { @@ -108,7 +108,7 @@ describe('deleteDocument', () => { describe('fetchDocuments', () => { it('returns documents array on success', async () => { const docs = [fakeDoc]; - // fetchDocuments calls .from().select().eq().order().order() โ€” need deeper chain + // 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 }); @@ -130,3 +130,538 @@ describe('fetchDocuments', () => { await expect(fetchDocuments(sb, 'org-1')).rejects.toEqual({ message: 'fetch failed' }); }); }); + +// โ”€โ”€ updateDocument โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('updateDocument', () => { + it('updates and returns document', async () => { + const updated = { ...fakeDoc, name: 'Renamed' }; + const sb = mockSupabaseSuccess(updated); + const result = await updateDocument(sb, 'doc-1', { name: 'Renamed' }); + expect(result.name).toBe('Renamed'); + }); + + it('throws on error', async () => { + const sb = mockSupabaseError('update failed'); + await expect(updateDocument(sb, 'doc-1', { name: 'X' })) + .rejects.toEqual({ message: 'update failed', code: 'ERROR' }); + }); +}); + +// โ”€โ”€ moveDocument โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('moveDocument', () => { + it('moves document to new parent', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(moveDocument(sb, 'doc-1', 'folder-2')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'move failed', code: 'ERROR' } }); + await expect(moveDocument(sb, 'doc-1', 'folder-2')) + .rejects.toEqual({ message: 'move failed', code: 'ERROR' }); + }); +}); + +// โ”€โ”€ fetchFolderContents โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchFolderContents', () => { + it('returns folder contents', async () => { + const docs = [fakeDoc]; + 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 fetchFolderContents(sb, 'folder-1'); + expect(result).toEqual(docs); + }); + + it('returns empty array when null', async () => { + const orderFn2 = vi.fn().mockResolvedValue({ data: null, 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; + + expect(await fetchFolderContents(sb, 'folder-1')).toEqual([]); + }); + + it('throws on error', async () => { + const orderFn2 = vi.fn().mockResolvedValue({ data: null, error: { message: 'fail' } }); + 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(fetchFolderContents(sb, 'folder-1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ findDepartmentFolder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('findDepartmentFolder', () => { + it('returns folder when found', async () => { + const folder = { id: 'f1', name: 'Dept', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve({ data: folder, error: null })); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await findDepartmentFolder(sb, 'dept-1'); + expect(result).toEqual(folder); + }); + + it('returns null when not found', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve({ data: null, error: null })); + const sb = { from: vi.fn(() => chain) } as any; + + expect(await findDepartmentFolder(sb, 'dept-1')).toBeNull(); + }); +}); + +// โ”€โ”€ Pure utility functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('getFileMetadata', () => { + it('returns metadata for file type with storage_path', () => { + const doc = { type: 'file', content: { storage_path: '/path/to/file', mime_type: 'image/png', size: 1024 } } as any; + const result = getFileMetadata(doc); + expect(result).not.toBeNull(); + expect(result!.storage_path).toBe('/path/to/file'); + }); + + it('returns null for non-file type', () => { + const doc = { type: 'folder', content: null } as any; + expect(getFileMetadata(doc)).toBeNull(); + }); + + it('returns null when content is null', () => { + const doc = { type: 'file', content: null } as any; + expect(getFileMetadata(doc)).toBeNull(); + }); + + it('returns null when no storage_path', () => { + const doc = { type: 'file', content: { mime_type: 'text/plain' } } as any; + expect(getFileMetadata(doc)).toBeNull(); + }); +}); + +describe('formatFileSize', () => { + it('formats bytes', () => { + expect(formatFileSize(500)).toBe('500 B'); + }); + + it('formats kilobytes', () => { + expect(formatFileSize(2048)).toBe('2.0 KB'); + }); + + it('formats megabytes', () => { + expect(formatFileSize(5 * 1024 * 1024)).toBe('5.0 MB'); + }); + + it('formats gigabytes', () => { + expect(formatFileSize(2 * 1024 * 1024 * 1024)).toBe('2.0 GB'); + }); +}); + +// โ”€โ”€ findFinanceFolder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('findFinanceFolder', () => { + it('returns finance folder when event folder and finance folder exist', async () => { + const eventFolder = { id: 'ef1' }; + const financeFolder = { id: 'ff1', name: 'Finance', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.single = vi.fn(() => { + callIdx++; + if (callIdx === 1) return Promise.resolve({ data: eventFolder, error: null }); + return Promise.resolve({ data: financeFolder, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await findFinanceFolder(sb, 'event-1'); + expect(result).toEqual(financeFolder); + }); + + it('returns null when event folder not found', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve({ data: null, error: null })); + const sb = { from: vi.fn(() => chain) } as any; + + expect(await findFinanceFolder(sb, 'event-1')).toBeNull(); + }); + + it('returns null when finance folder not found inside event folder', async () => { + const eventFolder = { id: 'ef1' }; + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.single = vi.fn(() => { + callIdx++; + if (callIdx === 1) return Promise.resolve({ data: eventFolder, error: null }); + return Promise.resolve({ data: null, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + expect(await findFinanceFolder(sb, 'event-1')).toBeNull(); + }); +}); + +// โ”€โ”€ deleteFileFromStorage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('deleteFileFromStorage', () => { + it('deletes file from storage', async () => { + const removeFn = vi.fn().mockResolvedValue({ error: null }); + const sb = { storage: { from: vi.fn().mockReturnValue({ remove: removeFn }) } } as any; + await expect(deleteFileFromStorage(sb, 'org/root/file.txt')).resolves.toBeUndefined(); + expect(removeFn).toHaveBeenCalledWith(['org/root/file.txt']); + }); + + it('throws on storage error', async () => { + const removeFn = vi.fn().mockResolvedValue({ error: { message: 'storage fail' } }); + const sb = { storage: { from: vi.fn().mockReturnValue({ remove: removeFn }) } } as any; + await expect(deleteFileFromStorage(sb, 'org/root/file.txt')).rejects.toEqual({ message: 'storage fail' }); + }); +}); + +// โ”€โ”€ ensureEventsFolder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('ensureEventsFolder', () => { + it('returns existing Events folder if found', async () => { + const folder = { id: 'ef1', name: 'Events', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve({ data: folder, error: null })); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await ensureEventsFolder(sb, 'org-1', 'user-1'); + expect(result).toEqual(folder); + }); + + it('creates Events folder when not found', async () => { + const newFolder = { id: 'ef2', name: 'Events', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: null, error: null }); // not found + return Promise.resolve({ data: newFolder, error: null }); // created + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await ensureEventsFolder(sb, 'org-1', 'user-1'); + expect(result).toEqual(newFolder); + }); +}); + +// โ”€โ”€ uploadFile โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('uploadFile', () => { + it('uploads file and creates document row', async () => { + const doc = { id: 'doc-1', name: 'photo.png', type: 'file' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve({ data: doc, error: null })); + + const sb = { + from: vi.fn(() => chain), + storage: { + from: vi.fn().mockReturnValue({ + upload: vi.fn().mockResolvedValue({ error: null }), + getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://example.com/photo.png' } }), + remove: vi.fn().mockResolvedValue({ error: null }), + }), + }, + } as any; + + const file = new File(['data'], 'photo.png', { type: 'image/png' }); + const result = await uploadFile(sb, 'org-1', 'folder-1', 'user-1', file); + expect(result).toEqual(doc); + }); + + it('throws on upload error', async () => { + const sb = { + from: vi.fn(), + storage: { + from: vi.fn().mockReturnValue({ + upload: vi.fn().mockResolvedValue({ error: { message: 'upload fail' } }), + }), + }, + } as any; + + const file = new File(['data'], 'test.txt', { type: 'text/plain' }); + await expect(uploadFile(sb, 'org-1', null, 'user-1', file)).rejects.toEqual({ message: 'upload fail' }); + }); + + it('cleans up storage on DB insert error', async () => { + const removeFn = vi.fn().mockResolvedValue({ error: null }); + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve({ data: null, error: { message: 'db fail' } })); + + const sb = { + from: vi.fn(() => chain), + storage: { + from: vi.fn().mockReturnValue({ + upload: vi.fn().mockResolvedValue({ error: null }), + getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://example.com/f.txt' } }), + remove: removeFn, + }), + }, + } as any; + + const file = new File(['data'], 'test.txt', { type: 'text/plain' }); + await expect(uploadFile(sb, 'org-1', null, 'user-1', file)).rejects.toEqual({ message: 'db fail' }); + expect(removeFn).toHaveBeenCalled(); + }); +}); + +// โ”€โ”€ createEventFolder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('createEventFolder', () => { + it('returns existing event folder if found', async () => { + const eventsFolder = { id: 'ef1', name: 'Events' }; + const eventFolder = { id: 'evf1', name: 'Conf', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); // ensureEventsFolder + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); // existing event folder + return Promise.resolve({ data: null, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await createEventFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf'); + expect(result).toEqual(eventFolder); + }); + + it('creates event folder when not found', async () => { + const eventsFolder = { id: 'ef1', name: 'Events' }; + const newFolder = { id: 'evf2', name: 'Conf', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); // ensureEventsFolder + if (singleIdx === 2) return Promise.resolve({ data: null, error: null }); // not found + return Promise.resolve({ data: newFolder, error: null }); // created + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await createEventFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf'); + expect(result).toEqual(newFolder); + }); +}); + +// โ”€โ”€ createDepartmentFolder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('createDepartmentFolder', () => { + it('returns existing dept folder if found', async () => { + const eventsFolder = { id: 'ef1', name: 'Events' }; + const eventFolder = { id: 'evf1', name: 'Conf' }; + const deptFolder = { id: 'df1', name: 'Logistics', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); // ensureEventsFolder + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); // createEventFolder existing + if (singleIdx === 3) return Promise.resolve({ data: deptFolder, error: null }); // existing dept folder + return Promise.resolve({ data: null, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await createDepartmentFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Logistics'); + expect(result).toEqual(deptFolder); + }); + + it('creates dept folder when not found', async () => { + const eventsFolder = { id: 'ef1', name: 'Events' }; + const eventFolder = { id: 'evf1', name: 'Conf' }; + const newDeptFolder = { id: 'df2', name: 'Marketing', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); + if (singleIdx === 3) return Promise.resolve({ data: null, error: null }); // not found + return Promise.resolve({ data: newDeptFolder, error: null }); // created + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await createDepartmentFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Marketing'); + expect(result).toEqual(newDeptFolder); + }); +}); + +// โ”€โ”€ ensureFinanceFolder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('ensureFinanceFolder', () => { + it('returns existing Finance folder if found', async () => { + const eventsFolder = { id: 'ef1', name: 'Events' }; + const eventFolder = { id: 'evf1', name: 'Conf' }; + const financeFolder = { id: 'ff1', name: 'Finance', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); + if (singleIdx === 3) return Promise.resolve({ data: financeFolder, error: null }); // existing + return Promise.resolve({ data: null, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await ensureFinanceFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf'); + expect(result).toEqual(financeFolder); + }); + + it('creates Finance folder when not found', async () => { + const eventsFolder = { id: 'ef1', name: 'Events' }; + const eventFolder = { id: 'evf1', name: 'Conf' }; + const newFinance = { id: 'ff2', name: 'Finance', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); + if (singleIdx === 3) return Promise.resolve({ data: null, error: null }); // not found + return Promise.resolve({ data: newFinance, error: null }); // created + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await ensureFinanceFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf'); + expect(result).toEqual(newFinance); + }); +}); + +// โ”€โ”€ ensureFinanceDeptFolder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('ensureFinanceDeptFolder', () => { + it('returns existing finance dept folder if found', async () => { + const eventsFolder = { id: 'ef1' }; + const eventFolder = { id: 'evf1' }; + const financeFolder = { id: 'ff1' }; + const deptFolder = { id: 'fdf1', name: 'Marketing', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); + if (singleIdx === 3) return Promise.resolve({ data: financeFolder, error: null }); + if (singleIdx === 4) return Promise.resolve({ data: deptFolder, error: null }); // existing + return Promise.resolve({ data: null, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await ensureFinanceDeptFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Marketing'); + expect(result).toEqual(deptFolder); + }); + + it('creates finance dept folder when not found', async () => { + const eventsFolder = { id: 'ef1' }; + const eventFolder = { id: 'evf1' }; + const financeFolder = { id: 'ff1' }; + const newDeptFolder = { id: 'fdf2', name: 'Marketing', type: 'folder' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); + if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); + if (singleIdx === 3) return Promise.resolve({ data: financeFolder, error: null }); + if (singleIdx === 4) return Promise.resolve({ data: null, error: null }); // not found + return Promise.resolve({ data: newDeptFolder, error: null }); // created + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await ensureFinanceDeptFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Marketing'); + expect(result).toEqual(newDeptFolder); + }); +}); + +// โ”€โ”€ subscribeToDocuments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('subscribeToDocuments', () => { + it('sets up realtime subscription', () => { + const channel: any = {}; + channel.on = vi.fn(() => channel); + channel.subscribe = vi.fn(() => channel); + const sb = { channel: vi.fn(() => channel) } as any; + + const result = subscribeToDocuments(sb, 'org-1', vi.fn(), vi.fn(), vi.fn()); + expect(sb.channel).toHaveBeenCalledWith('documents:org-1'); + expect(channel.on).toHaveBeenCalledTimes(3); + expect(channel.subscribe).toHaveBeenCalledOnce(); + expect(result).toBe(channel); + }); +}); diff --git a/src/lib/api/documents.ts b/src/lib/api/documents.ts index 05dcbea..ae519ea 100644 --- a/src/lib/api/documents.ts +++ b/src/lib/api/documents.ts @@ -133,6 +133,420 @@ export async function copyDocument( return data; } +// ============================================================ +// Auto-folder helpers for Events & Departments +// ============================================================ + +/** + * Find or create the org-level "Events" root folder. + * Uses a name + null parent_id lookup first to avoid duplicates. + */ +export async function ensureEventsFolder( + supabase: SupabaseClient, + orgId: string, + userId: string +): Promise { + // Look for existing "Events" folder at root level + const { data: existing } = await supabase + .from('documents') + .select('*') + .eq('org_id', orgId) + .eq('type', 'folder') + .eq('name', 'Events') + .is('parent_id', null) + .limit(1) + .single(); + + if (existing) return existing; + + // Create it + log.info('Creating Events root folder', { data: { orgId } }); + return createDocument(supabase, orgId, 'Events', 'folder', null, userId); +} + +/** + * Create a folder for a specific event inside the "Events" root folder. + * Also stores event_id on the document row for reliable lookup. + */ +export async function createEventFolder( + supabase: SupabaseClient, + orgId: string, + userId: string, + eventId: string, + eventName: string +): Promise { + const eventsFolder = await ensureEventsFolder(supabase, orgId, userId); + + // Check if folder already exists for this event + const { data: existing } = await (supabase as any) + .from('documents') + .select('*') + .eq('org_id', orgId) + .eq('type', 'folder') + .eq('event_id', eventId) + .eq('parent_id', eventsFolder.id) + .limit(1) + .single(); + + if (existing) return existing; + + log.info('Creating event folder', { data: { orgId, eventId, eventName } }); + const { data, error } = await (supabase as any) + .from('documents') + .insert({ + org_id: orgId, + name: eventName, + type: 'folder', + parent_id: eventsFolder.id, + created_by: userId, + content: null, + event_id: eventId, + }) + .select() + .single(); + + if (error) { + log.error('createEventFolder failed', { error, data: { orgId, eventId } }); + throw error; + } + return data; +} + +/** + * Create a folder for a department inside its event folder. + * Stores department_id on the document row for reliable lookup. + */ +export async function createDepartmentFolder( + supabase: SupabaseClient, + orgId: string, + userId: string, + eventId: string, + eventName: string, + departmentId: string, + departmentName: string +): Promise { + const eventFolder = await createEventFolder(supabase, orgId, userId, eventId, eventName); + + // Check if folder already exists for this department + const { data: existing } = await (supabase as any) + .from('documents') + .select('*') + .eq('org_id', orgId) + .eq('type', 'folder') + .eq('department_id', departmentId) + .eq('parent_id', eventFolder.id) + .limit(1) + .single(); + + if (existing) return existing; + + log.info('Creating department folder', { data: { orgId, eventId, departmentId, departmentName } }); + const { data, error } = await (supabase as any) + .from('documents') + .insert({ + org_id: orgId, + name: departmentName, + type: 'folder', + parent_id: eventFolder.id, + created_by: userId, + content: null, + event_id: eventId, + department_id: departmentId, + }) + .select() + .single(); + + if (error) { + log.error('createDepartmentFolder failed', { error, data: { orgId, departmentId } }); + throw error; + } + return data; +} + +/** + * Fetch all documents inside a specific folder. + */ +export async function fetchFolderContents( + supabase: SupabaseClient, + folderId: string +): Promise { + const { data, error } = await supabase + .from('documents') + .select('*') + .eq('parent_id', folderId) + .order('type', { ascending: false }) // folders first + .order('name'); + + if (error) { + log.error('fetchFolderContents failed', { error, data: { folderId } }); + throw error; + } + return data ?? []; +} + +/** + * Find the department folder by department_id. Returns null if not found. + */ +export async function findDepartmentFolder( + supabase: SupabaseClient, + departmentId: string +): Promise { + const { data } = await (supabase as any) + .from('documents') + .select('*') + .eq('type', 'folder') + .eq('department_id', departmentId) + .limit(1) + .single(); + + return data ?? null; +} + +// ============================================================ +// Finance folder helpers +// ============================================================ + +/** + * Find or create a "Finance" folder inside the event folder. + * Used as the root for all department finance documents (invoices, receipts). + */ +export async function ensureFinanceFolder( + supabase: SupabaseClient, + orgId: string, + userId: string, + eventId: string, + eventName: string +): Promise { + const eventFolder = await createEventFolder(supabase, orgId, userId, eventId, eventName); + + // Look for existing "Finance" folder inside event folder + const { data: existing } = await supabase + .from('documents') + .select('*') + .eq('org_id', orgId) + .eq('type', 'folder') + .eq('name', 'Finance') + .eq('parent_id', eventFolder.id) + .limit(1) + .single(); + + if (existing) return existing; + + log.info('Creating Finance folder', { data: { orgId, eventId } }); + const { data, error } = await (supabase as any) + .from('documents') + .insert({ + org_id: orgId, + name: 'Finance', + type: 'folder', + parent_id: eventFolder.id, + created_by: userId, + content: null, + event_id: eventId, + }) + .select() + .single(); + + if (error) { + log.error('ensureFinanceFolder failed', { error, data: { orgId, eventId } }); + throw error; + } + return data; +} + +/** + * Find or create a department subfolder inside the Finance folder. + * e.g. Events > [Event] > Finance > [Department Name] + */ +export async function ensureFinanceDeptFolder( + supabase: SupabaseClient, + orgId: string, + userId: string, + eventId: string, + eventName: string, + departmentId: string, + departmentName: string +): Promise { + const financeFolder = await ensureFinanceFolder(supabase, orgId, userId, eventId, eventName); + + // Look for existing dept subfolder inside Finance folder + const { data: existing } = await (supabase as any) + .from('documents') + .select('*') + .eq('org_id', orgId) + .eq('type', 'folder') + .eq('department_id', departmentId) + .eq('parent_id', financeFolder.id) + .limit(1) + .single(); + + if (existing) return existing; + + log.info('Creating finance dept folder', { data: { orgId, departmentId, departmentName } }); + const { data, error } = await (supabase as any) + .from('documents') + .insert({ + org_id: orgId, + name: departmentName, + type: 'folder', + parent_id: financeFolder.id, + created_by: userId, + content: null, + event_id: eventId, + department_id: departmentId, + }) + .select() + .single(); + + if (error) { + log.error('ensureFinanceDeptFolder failed', { error, data: { orgId, departmentId } }); + throw error; + } + return data; +} + +/** + * Find the Finance folder for an event. Returns null if not found. + */ +export async function findFinanceFolder( + supabase: SupabaseClient, + eventId: string +): Promise { + // Find the event folder first + const { data: eventFolder } = await (supabase as any) + .from('documents') + .select('id') + .eq('type', 'folder') + .eq('event_id', eventId) + .is('department_id', null) + .limit(1) + .single(); + + if (!eventFolder) return null; + + const { data } = await (supabase as any) + .from('documents') + .select('*') + .eq('type', 'folder') + .eq('name', 'Finance') + .eq('parent_id', eventFolder.id) + .limit(1) + .single(); + + return data ?? null; +} + +// ============================================================ +// File Upload helpers +// ============================================================ + +export interface FileMetadata { + storage_path: string; + file_name: string; + file_size: number; + mime_type: string; + public_url: string; +} + +/** + * Upload a file to Supabase Storage and create a document row of type "file". + * Files are stored under: {orgId}/{parentId}/{timestamp}_{filename} + */ +export async function uploadFile( + supabase: SupabaseClient, + orgId: string, + parentId: string | null, + userId: string, + file: File +): Promise { + const timestamp = Date.now(); + const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + const storagePath = `${orgId}/${parentId ?? 'root'}/${timestamp}_${safeName}`; + + log.info('Uploading file', { data: { orgId, fileName: file.name, size: file.size, storagePath } }); + + const { error: uploadError } = await supabase.storage + .from('files') + .upload(storagePath, file, { + cacheControl: '3600', + upsert: false, + }); + + if (uploadError) { + log.error('File upload failed', { error: uploadError, data: { storagePath } }); + throw uploadError; + } + + // Get public URL + const { data: urlData } = supabase.storage.from('files').getPublicUrl(storagePath); + + const metadata: FileMetadata = { + storage_path: storagePath, + file_name: file.name, + file_size: file.size, + mime_type: file.type || 'application/octet-stream', + public_url: urlData.publicUrl, + }; + + // Create document row with type "file" + const { data, error } = await supabase + .from('documents') + .insert({ + org_id: orgId, + name: file.name, + type: 'file', + parent_id: parentId, + created_by: userId, + content: metadata as unknown as import('$lib/supabase/types').Json, + }) + .select() + .single(); + + if (error) { + // Clean up storage on DB failure + await supabase.storage.from('files').remove([storagePath]); + log.error('File document creation failed', { error, data: { storagePath } }); + throw error; + } + + log.info('File uploaded successfully', { data: { id: data.id, name: file.name, size: file.size } }); + return data; +} + +/** + * Delete a file from Storage when its document row is deleted. + */ +export async function deleteFileFromStorage( + supabase: SupabaseClient, + storagePath: string +): Promise { + const { error } = await supabase.storage.from('files').remove([storagePath]); + if (error) { + log.error('deleteFileFromStorage failed', { error, data: { storagePath } }); + throw error; + } +} + +/** + * Get the file metadata from a document's content field. + */ +export function getFileMetadata(doc: Document): FileMetadata | null { + if (doc.type !== 'file' || !doc.content) return null; + const content = doc.content as unknown as FileMetadata; + if (!content.storage_path) return null; + return content; +} + +/** + * Format file size in human-readable form. + */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + export function subscribeToDocuments( supabase: SupabaseClient, orgId: string, diff --git a/src/lib/api/event-tasks.test.ts b/src/lib/api/event-tasks.test.ts new file mode 100644 index 0000000..217809c --- /dev/null +++ b/src/lib/api/event-tasks.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchTaskColumns, + createTaskColumn, + renameTaskColumn, + deleteTaskColumn, + createTask, + updateTask, + deleteTask, + moveTask, + subscribeToEventTasks, +} from './event-tasks'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any; count?: number }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + // For count queries + if (resolvedValue.count !== undefined) { + chain.count = resolvedValue.count; + } + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any; count?: number }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ Columns โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchTaskColumns', () => { + it('returns columns with tasks grouped', async () => { + const columns = [{ id: 'c1', name: 'To Do', event_id: 'e1', position: 0 }]; + const tasks = [{ id: 't1', title: 'Task 1', event_id: 'e1', column_id: 'c1', position: 0 }]; + + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.then = (resolve: any) => { + callIdx++; + if (callIdx === 1) return resolve({ data: columns, error: null }); + return resolve({ data: tasks, error: null }); + }; + const sb = { from: vi.fn(() => chain) } as any; + + const result = await fetchTaskColumns(sb, 'e1'); + expect(result).toHaveLength(1); + expect(result[0].cards).toHaveLength(1); + expect(result[0].cards[0].title).toBe('Task 1'); + }); + + it('throws when column fetch fails', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'col fail' } }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(fetchTaskColumns(sb, 'e1')).rejects.toEqual({ message: 'col fail' }); + }); +}); + +describe('createTaskColumn', () => { + it('creates column with explicit position', async () => { + const col = { id: 'c1', name: 'Done', event_id: 'e1', position: 2 }; + const sb = mockSupabase({ data: col, error: null }); + expect(await createTaskColumn(sb, 'e1', 'Done', 2)).toEqual(col); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createTaskColumn(sb, 'e1', 'X', 0)).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('renameTaskColumn', () => { + it('renames without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(renameTaskColumn(sb, 'c1', 'Renamed')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(renameTaskColumn(sb, 'c1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteTaskColumn', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteTaskColumn(sb, 'c1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteTaskColumn(sb, 'c1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Tasks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('createTask', () => { + it('creates task', async () => { + const task = { id: 't1', title: 'New Task', event_id: 'e1', column_id: 'c1', position: 0 }; + // Need two calls: count query + insert + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.then = (resolve: any) => { + callIdx++; + if (callIdx === 1) return resolve({ count: 0, error: null }); + return resolve({ data: task, error: null }); + }; + chain.single = vi.fn(() => { + return Promise.resolve({ data: task, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await createTask(sb, 'e1', 'c1', 'New Task', 'user1'); + expect(result).toEqual(task); + }); +}); + +describe('updateTask', () => { + it('updates without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(updateTask(sb, 't1', { title: 'Updated' })).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateTask(sb, 't1', { title: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteTask', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteTask(sb, 't1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteTask(sb, 't1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('moveTask', () => { + it('reorders tasks in target column', async () => { + const colTasks = [ + { id: 't1', position: 0 }, + { id: 't2', position: 1 }, + ]; + const chain: any = {}; + const methods = ['from', 'select', 'update', 'eq', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: colTasks, error: null }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(moveTask(sb, 't3', 'c1', 1)).resolves.toBeUndefined(); + }); + + it('throws when fetch fails', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fetch fail' } }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(moveTask(sb, 't1', 'c1', 0)).rejects.toEqual({ message: 'fetch fail' }); + }); +}); + +describe('subscribeToEventTasks', () => { + it('sets up realtime subscription and returns channel', () => { + const channel: any = {}; + channel.on = vi.fn(() => channel); + channel.subscribe = vi.fn(() => channel); + const sb = { channel: vi.fn(() => channel) } as any; + + const result = subscribeToEventTasks(sb, 'e1', ['c1'], vi.fn(), vi.fn()); + expect(sb.channel).toHaveBeenCalledWith('event-tasks:e1'); + expect(channel.on).toHaveBeenCalledTimes(2); + expect(channel.subscribe).toHaveBeenCalledOnce(); + expect(result).toBe(channel); + }); +}); diff --git a/src/lib/api/events.test.ts b/src/lib/api/events.test.ts index 7365d75..d067fda 100644 --- a/src/lib/api/events.test.ts +++ b/src/lib/api/events.test.ts @@ -1,48 +1,410 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { + fetchEvents, + fetchEvent, + fetchEventBySlug, + createEvent, + updateEvent, + deleteEvent, + fetchEventMembers, + addEventMember, + removeEventMember, + fetchEventRoles, + createEventRole, + updateEventRole, + deleteEventRole, + fetchEventDepartments, + createEventDepartment, + updateEventDepartment, + updateDepartmentPlannedBudget, + deleteEventDepartment, + assignMemberDepartment, + unassignMemberDepartment, +} from './events'; -// Test the slugify logic (extracted inline since it's not exported) -function slugify(text: string): string { - return text - .toLowerCase() - .replace(/[^\w\s-]/g, '') - .replace(/[\s_]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 60) || 'event'; +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'upsert', 'update', 'delete', 'eq', 'in', 'like', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; } -describe('events API - slugify', () => { - it('converts simple name to slug', () => { - expect(slugify('Summer Conference')).toBe('summer-conference'); +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ fetchEvents โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchEvents', () => { + it('returns events with member counts', async () => { + const raw = [{ id: 'e1', name: 'Conf', event_members: [{ count: 5 }] }]; + const sb = mockSupabase({ data: raw, error: null }); + const result = await fetchEvents(sb, 'o1'); + expect(result).toHaveLength(1); + expect(result[0].member_count).toBe(5); }); - it('handles special characters', () => { - expect(slugify('Music & Arts Festival 2026!')).toBe('music-arts-festival-2026'); + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchEvents(sb, 'o1')).toEqual([]); }); - it('collapses multiple spaces and dashes', () => { - expect(slugify('My Big Event')).toBe('my-big-event'); - }); - - it('trims leading/trailing dashes', () => { - expect(slugify('--hello--')).toBe('hello'); - }); - - it('truncates to 60 characters', () => { - const longName = 'A'.repeat(100); - expect(slugify(longName).length).toBeLessThanOrEqual(60); - }); - - it('returns "event" for empty string', () => { - expect(slugify('')).toBe('event'); - }); - - it('handles unicode characters', () => { - const result = slugify('รœrituse Korraldamine'); - expect(result).toBe('rituse-korraldamine'); - }); - - it('handles numbers', () => { - expect(slugify('Event 2026 Q1')).toBe('event-2026-q1'); + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchEvents(sb, 'o1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ fetchEvent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchEvent', () => { + it('returns event by id', async () => { + const event = { id: 'e1', name: 'Conf' }; + const sb = mockSupabase({ data: event, error: null }); + expect(await fetchEvent(sb, 'e1')).toEqual(event); + }); + + it('returns null when not found', async () => { + const sb = mockSupabase({ data: null, error: { code: 'PGRST116', message: 'not found' } }); + expect(await fetchEvent(sb, 'e1')).toBeNull(); + }); + + it('throws on other errors', async () => { + const sb = mockSupabase({ data: null, error: { code: '42000', message: 'fail' } }); + await expect(fetchEvent(sb, 'e1')).rejects.toEqual({ code: '42000', message: 'fail' }); + }); +}); + +// โ”€โ”€ fetchEventBySlug โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchEventBySlug', () => { + it('returns event by slug', async () => { + const event = { id: 'e1', slug: 'conf' }; + const sb = mockSupabase({ data: event, error: null }); + expect(await fetchEventBySlug(sb, 'o1', 'conf')).toEqual(event); + }); + + it('returns null when not found', async () => { + const sb = mockSupabase({ data: null, error: { code: 'PGRST116', message: 'not found' } }); + expect(await fetchEventBySlug(sb, 'o1', 'nope')).toBeNull(); + }); + + it('throws on other errors', async () => { + const sb = mockSupabase({ data: null, error: { code: '42000', message: 'fail' } }); + await expect(fetchEventBySlug(sb, 'o1', 'x')).rejects.toEqual({ code: '42000', message: 'fail' }); + }); +}); + +// โ”€โ”€ createEvent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('createEvent', () => { + it('creates event with unique slug', async () => { + const event = { id: 'e1', name: 'Conf', slug: 'conf' }; + // Two calls: slug check + insert + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'like', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.then = (resolve: any) => { + callIdx++; + if (callIdx === 1) return resolve({ data: [], error: null }); // no existing slugs + return resolve({ data: event, error: null }); + }; + chain.single = vi.fn(() => Promise.resolve({ data: event, error: null })); + const sb = { from: vi.fn(() => chain) } as any; + + const result = await createEvent(sb, 'o1', 'u1', { name: 'Conf' }); + expect(result).toEqual(event); + }); + + it('throws on insert error', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'like', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let callIdx = 0; + chain.then = (resolve: any) => { + callIdx++; + if (callIdx === 1) return resolve({ data: [], error: null }); + return resolve({ data: null, error: { message: 'fail' } }); + }; + chain.single = vi.fn(() => Promise.resolve({ data: null, error: { message: 'fail' } })); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(createEvent(sb, 'o1', 'u1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ updateEvent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('updateEvent', () => { + it('updates and returns event', async () => { + const event = { id: 'e1', name: 'Updated' }; + const sb = mockSupabase({ data: event, error: null }); + expect(await updateEvent(sb, 'e1', { name: 'Updated' })).toEqual(event); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateEvent(sb, 'e1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ deleteEvent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('deleteEvent', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteEvent(sb, 'e1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteEvent(sb, 'e1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ fetchEventMembers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchEventMembers', () => { + it('returns empty array when no members', async () => { + const sb = mockSupabase({ data: [], error: null }); + expect(await fetchEventMembers(sb, 'e1')).toEqual([]); + }); + + it('returns members with profiles, roles, and departments', async () => { + const members = [{ id: 'm1', event_id: 'e1', user_id: 'u1', role_id: 'r1' }]; + const profiles = [{ id: 'u1', email: 'a@b.com', full_name: 'Alice' }]; + const roles = [{ id: 'r1', event_id: 'e1', name: 'Lead' }]; + const memberDepts = [{ event_member_id: 'm1', department_id: 'd1' }]; + const departments = [{ id: 'd1', event_id: 'e1', name: 'Logistics' }]; + + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + + let callIdx = 0; + chain.then = (resolve: any) => { + callIdx++; + switch (callIdx) { + case 1: return resolve({ data: members, error: null }); // members + case 2: return resolve({ data: profiles, error: null }); // profiles + case 3: return resolve({ data: roles, error: null }); // roles + case 4: return resolve({ data: memberDepts, error: null }); // member-depts + case 5: return resolve({ data: departments, error: null }); // departments + default: return resolve({ data: [], error: null }); + } + }; + const sb = { from: vi.fn(() => chain) } as any; + + const result = await fetchEventMembers(sb, 'e1'); + expect(result).toHaveLength(1); + expect(result[0].profile?.full_name).toBe('Alice'); + expect(result[0].event_role?.name).toBe('Lead'); + expect(result[0].departments).toHaveLength(1); + expect(result[0].departments[0].name).toBe('Logistics'); + }); + + it('throws on error', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fail' } }); + const sb = { from: vi.fn(() => chain) } as any; + await expect(fetchEventMembers(sb, 'e1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ addEventMember โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('addEventMember', () => { + it('adds member', async () => { + const member = { id: 'm1', event_id: 'e1', user_id: 'u1', role: 'member' }; + const sb = mockSupabase({ data: member, error: null }); + expect(await addEventMember(sb, 'e1', 'u1')).toEqual(member); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(addEventMember(sb, 'e1', 'u1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ removeEventMember โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('removeEventMember', () => { + it('removes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(removeEventMember(sb, 'e1', 'u1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(removeEventMember(sb, 'e1', 'u1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Event Roles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchEventRoles', () => { + it('returns roles', async () => { + const roles = [{ id: 'r1', name: 'Lead' }]; + const sb = mockSupabase({ data: roles, error: null }); + expect(await fetchEventRoles(sb, 'e1')).toEqual(roles); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchEventRoles(sb, 'e1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createEventRole', () => { + it('creates role', async () => { + const role = { id: 'r1', name: 'Lead', color: '#6366f1' }; + const sb = mockSupabase({ data: role, error: null }); + expect(await createEventRole(sb, 'e1', { name: 'Lead' })).toEqual(role); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createEventRole(sb, 'e1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateEventRole', () => { + it('updates role', async () => { + const role = { id: 'r1', name: 'Updated' }; + const sb = mockSupabase({ data: role, error: null }); + expect(await updateEventRole(sb, 'r1', { name: 'Updated' })).toEqual(role); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateEventRole(sb, 'r1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteEventRole', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteEventRole(sb, 'r1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteEventRole(sb, 'r1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Event Departments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchEventDepartments', () => { + it('returns departments', async () => { + const depts = [{ id: 'd1', name: 'Logistics' }]; + const sb = mockSupabase({ data: depts, error: null }); + expect(await fetchEventDepartments(sb, 'e1')).toEqual(depts); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchEventDepartments(sb, 'e1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchEventDepartments(sb, 'e1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createEventDepartment', () => { + it('creates department', async () => { + const dept = { id: 'd1', name: 'Marketing', color: '#00A3E0' }; + const sb = mockSupabase({ data: dept, error: null }); + expect(await createEventDepartment(sb, 'e1', { name: 'Marketing' })).toEqual(dept); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createEventDepartment(sb, 'e1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateEventDepartment', () => { + it('updates department', async () => { + const dept = { id: 'd1', name: 'Updated' }; + const sb = mockSupabase({ data: dept, error: null }); + expect(await updateEventDepartment(sb, 'd1', { name: 'Updated' })).toEqual(dept); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateEventDepartment(sb, 'd1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateDepartmentPlannedBudget', () => { + it('updates planned budget', async () => { + const dept = { id: 'd1', planned_budget: 5000 }; + const sb = mockSupabase({ data: dept, error: null }); + expect(await updateDepartmentPlannedBudget(sb, 'd1', 5000)).toEqual(dept); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateDepartmentPlannedBudget(sb, 'd1', 0)).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteEventDepartment', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteEventDepartment(sb, 'd1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteEventDepartment(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Member-Department Assignments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('assignMemberDepartment', () => { + it('assigns member to department', async () => { + const md = { id: 'md1', event_member_id: 'm1', department_id: 'd1' }; + const sb = mockSupabase({ data: md, error: null }); + expect(await assignMemberDepartment(sb, 'm1', 'd1')).toEqual(md); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(assignMemberDepartment(sb, 'm1', 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('unassignMemberDepartment', () => { + it('unassigns without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(unassignMemberDepartment(sb, 'm1', 'd1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(unassignMemberDepartment(sb, 'm1', 'd1')).rejects.toEqual({ message: 'fail' }); }); }); diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index 11f903b..2314759 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -48,6 +48,7 @@ export interface EventDepartment { name: string; color: string; description: string | null; + planned_budget: number; sort_order: number; created_at: string | null; } @@ -492,6 +493,25 @@ export async function updateEventDepartment( return data as unknown as EventDepartment; } +export async function updateDepartmentPlannedBudget( + supabase: SupabaseClient, + deptId: string, + plannedBudget: number +): Promise { + const { data, error } = await (supabase as any) + .from('event_departments') + .update({ planned_budget: plannedBudget }) + .eq('id', deptId) + .select() + .single(); + + if (error) { + log.error('updateDepartmentPlannedBudget failed', { error, data: { deptId, plannedBudget } }); + throw error; + } + return data as unknown as EventDepartment; +} + export async function deleteEventDepartment( supabase: SupabaseClient, deptId: string diff --git a/src/lib/api/google-calendar-push.test.ts b/src/lib/api/google-calendar-push.test.ts new file mode 100644 index 0000000..0aeebd0 --- /dev/null +++ b/src/lib/api/google-calendar-push.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { getServiceAccountEmail } from './google-calendar-push'; + +// โ”€โ”€ getServiceAccountEmail (pure function, no network) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('getServiceAccountEmail', () => { + it('extracts email from valid JSON key', () => { + const key = JSON.stringify({ + client_email: 'test@project.iam.gserviceaccount.com', + private_key: 'fake-key', + }); + expect(getServiceAccountEmail(key)).toBe('test@project.iam.gserviceaccount.com'); + }); + + it('extracts email from base64-encoded JSON key', () => { + const json = JSON.stringify({ + client_email: 'b64@project.iam.gserviceaccount.com', + private_key: 'fake-key', + }); + const b64 = Buffer.from(json).toString('base64'); + expect(getServiceAccountEmail(b64)).toBe('b64@project.iam.gserviceaccount.com'); + }); + + it('returns null for invalid key', () => { + expect(getServiceAccountEmail('not-json-or-base64')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(getServiceAccountEmail('')).toBeNull(); + }); +}); diff --git a/src/lib/api/google-calendar-push.ts b/src/lib/api/google-calendar-push.ts index b825a6f..224e091 100644 --- a/src/lib/api/google-calendar-push.ts +++ b/src/lib/api/google-calendar-push.ts @@ -75,7 +75,7 @@ export function getServiceAccountEmail(keyJson: string): string | 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. + * No need for the calendar to be public - just shared with the service account. */ export async function fetchCalendarEventsViaServiceAccount( keyJson: string, @@ -207,7 +207,7 @@ export async function deleteGoogleEvent( } ); - // 410 Gone means already deleted โ€” treat as success + // 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 } }); diff --git a/src/lib/api/google-calendar.test.ts b/src/lib/api/google-calendar.test.ts index 4de9fe8..1b60bb2 100644 --- a/src/lib/api/google-calendar.test.ts +++ b/src/lib/api/google-calendar.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { extractCalendarId, getCalendarSubscribeUrl } from './google-calendar'; +import { describe, it, expect, vi } from 'vitest'; +import { extractCalendarId, getCalendarSubscribeUrl, fetchPublicCalendarEvents } from './google-calendar'; describe('extractCalendarId', () => { it('returns null for empty input', () => { @@ -59,3 +59,55 @@ describe('getCalendarSubscribeUrl', () => { expect(extractCalendarId(url)).toBe(calId); }); }); + +// โ”€โ”€ fetchPublicCalendarEvents โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchPublicCalendarEvents', () => { + it('returns events on success', async () => { + const events = [{ id: 'e1', summary: 'Meeting' }]; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items: events }), + }); + vi.stubGlobal('fetch', mockFetch); + + const result = await fetchPublicCalendarEvents( + 'cal@gmail.com', 'api-key', + new Date('2024-01-01'), new Date('2024-01-31') + ); + expect(result).toEqual(events); + expect(mockFetch).toHaveBeenCalledOnce(); + + vi.unstubAllGlobals(); + }); + + it('returns empty array when no items', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + })); + + const result = await fetchPublicCalendarEvents( + 'cal@gmail.com', 'key', + new Date(), new Date() + ); + expect(result).toEqual([]); + + vi.unstubAllGlobals(); + }); + + it('throws on non-ok response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + text: () => Promise.resolve('Forbidden'), + })); + + await expect(fetchPublicCalendarEvents( + 'cal@gmail.com', 'key', + new Date(), new Date() + )).rejects.toThrow('Failed to fetch calendar events (403)'); + + vi.unstubAllGlobals(); + }); +}); diff --git a/src/lib/api/kanban.test.ts b/src/lib/api/kanban.test.ts new file mode 100644 index 0000000..58e886e --- /dev/null +++ b/src/lib/api/kanban.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchBoards, + fetchBoardWithColumns, + createBoard, + updateBoard, + deleteBoard, + createColumn, + updateColumn, + deleteColumn, + createCard, + updateCard, + deleteCard, + moveCard, + subscribeToBoard, +} from './kanban'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ Boards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchBoards', () => { + it('returns boards for an org', async () => { + const boards = [{ id: 'b1', name: 'Board 1', org_id: 'o1' }]; + const sb = mockSupabase({ data: boards, error: null }); + expect(await fetchBoards(sb, 'o1')).toEqual(boards); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchBoards(sb, 'o1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchBoards(sb, 'o1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('fetchBoardWithColumns', () => { + it('returns board with columns and cards', async () => { + const board = { id: 'b1', name: 'Board', org_id: 'o1' }; + const columns = [{ id: 'c1', board_id: 'b1', name: 'To Do', position: 0 }]; + const cards = [{ id: 'k1', column_id: 'c1', title: 'Task', position: 0, assignee_id: null }]; + + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + + // Track calls: board.single, columns.order (thenable), cards.order (thenable), then tags/checklists/profiles + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: board, error: null }); + return Promise.resolve({ data: null, error: null }); + }); + + let thenIdx = 0; + chain.then = (resolve: any) => { + thenIdx++; + if (thenIdx === 1) return resolve({ data: columns, error: null }); // columns + if (thenIdx === 2) return resolve({ data: cards, error: null }); // cards + return resolve({ data: [], error: null }); // tags, checklists, profiles + }; + + const sb = { from: vi.fn(() => chain) } as any; + const result = await fetchBoardWithColumns(sb, 'b1'); + + expect(result).not.toBeNull(); + expect(result!.id).toBe('b1'); + expect(result!.columns).toHaveLength(1); + expect(result!.columns[0].cards).toHaveLength(1); + }); + + it('returns board with empty columns when no columns exist', async () => { + const board = { id: 'b1', name: 'Board', org_id: 'o1' }; + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: board, error: null }); + return Promise.resolve({ data: null, error: null }); + }); + + chain.then = (resolve: any) => resolve({ data: [], error: null }); // empty columns + + const sb = { from: vi.fn(() => chain) } as any; + const result = await fetchBoardWithColumns(sb, 'b1'); + + expect(result).not.toBeNull(); + expect(result!.columns).toEqual([]); + }); + + it('returns null when board not found', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + + chain.single = vi.fn(() => Promise.resolve({ data: null, error: null })); + chain.then = (resolve: any) => resolve({ data: [], error: null }); + + const sb = { from: vi.fn(() => chain) } as any; + const result = await fetchBoardWithColumns(sb, 'b1'); + expect(result).toBeNull(); + }); + + it('throws when board fetch fails', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'order', 'single', 'in']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => { + return Promise.resolve({ data: null, error: { message: 'board fail' } }); + }); + chain.then = (resolve: any) => resolve({ data: [], error: null }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(fetchBoardWithColumns(sb, 'b1')).rejects.toEqual({ message: 'board fail' }); + }); + + it('throws when columns fetch fails', async () => { + const board = { id: 'b1', name: 'Board' }; + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + + chain.single = vi.fn(() => Promise.resolve({ data: board, error: null })); + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'col fail' } }); + + const sb = { from: vi.fn(() => chain) } as any; + await expect(fetchBoardWithColumns(sb, 'b1')).rejects.toEqual({ message: 'col fail' }); + }); +}); + +describe('subscribeToBoard', () => { + it('sets up realtime subscription and returns channel', () => { + const channel: any = {}; + channel.on = vi.fn(() => channel); + channel.subscribe = vi.fn(() => channel); + const sb = { channel: vi.fn(() => channel) } as any; + + const result = subscribeToBoard(sb, 'b1', ['c1'], vi.fn(), vi.fn()); + expect(sb.channel).toHaveBeenCalledWith('kanban:b1'); + expect(channel.on).toHaveBeenCalledTimes(2); + expect(channel.subscribe).toHaveBeenCalledOnce(); + expect(result).toBe(channel); + }); +}); + +describe('createBoard', () => { + it('creates board and default columns', async () => { + const board = { id: 'b1', name: 'New Board', org_id: 'o1' }; + const sb = mockSupabase({ data: board, error: null }); + const result = await createBoard(sb, 'o1', 'New Board'); + expect(result).toEqual(board); + // Should have called from('kanban_columns') for default columns + expect(sb.from).toHaveBeenCalledWith('kanban_columns'); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createBoard(sb, 'o1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateBoard', () => { + it('updates without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(updateBoard(sb, 'b1', 'Renamed')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateBoard(sb, 'b1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteBoard', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteBoard(sb, 'b1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteBoard(sb, 'b1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Columns โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('createColumn', () => { + it('creates and returns column', async () => { + const col = { id: 'c1', name: 'To Do', board_id: 'b1', position: 0 }; + const sb = mockSupabase({ data: col, error: null }); + expect(await createColumn(sb, 'b1', 'To Do', 0)).toEqual(col); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createColumn(sb, 'b1', 'X', 0)).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateColumn', () => { + it('updates without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(updateColumn(sb, 'c1', { name: 'Done' })).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateColumn(sb, 'c1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteColumn', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteColumn(sb, 'c1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteColumn(sb, 'c1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('createCard', () => { + it('creates and returns card', async () => { + const card = { id: 'k1', title: 'Task', column_id: 'c1', position: 0 }; + const sb = mockSupabase({ data: card, error: null }); + expect(await createCard(sb, 'c1', 'Task', 0, 'user1')).toEqual(card); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createCard(sb, 'c1', 'X', 0, 'user1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateCard', () => { + it('updates without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(updateCard(sb, 'k1', { title: 'Updated' })).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateCard(sb, 'k1', { title: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteCard', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteCard(sb, 'k1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteCard(sb, 'k1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ moveCard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('moveCard', () => { + it('reorders cards in target column', async () => { + const targetCards = [ + { id: 'k1', position: 0 }, + { id: 'k2', position: 1 }, + ]; + const chain: any = {}; + const methods = ['from', 'select', 'update', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: targetCards, error: null }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(moveCard(sb, 'k3', 'col1', 1)).resolves.toBeUndefined(); + }); + + it('throws when fetch fails', async () => { + const chain: any = {}; + const methods = ['from', 'select', 'eq', 'in', 'order']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fetch fail' } }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(moveCard(sb, 'k1', 'col1', 0)).rejects.toEqual({ message: 'fetch fail' }); + }); +}); diff --git a/src/lib/api/map.ts b/src/lib/api/map.ts new file mode 100644 index 0000000..7f80dea --- /dev/null +++ b/src/lib/api/map.ts @@ -0,0 +1,252 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; + +export interface MapLayer { + id: string; + department_id: string; + name: string; + layer_type: 'osm' | 'image'; + image_url: string | null; + image_width: number | null; + image_height: number | null; + center_lat: number; + center_lng: number; + zoom_level: number; + sort_order: number; + created_by: string | null; + created_at: string; + updated_at: string; +} + +export interface MapPin { + id: string; + layer_id: string; + label: string; + description: string; + color: string; + lat: number; + lng: number; + bounds_north: number | null; + bounds_south: number | null; + bounds_east: number | null; + bounds_west: number | null; + sort_order: number; + created_by: string | null; + created_at: string; + updated_at: string; +} + +export interface MapShape { + id: string; + layer_id: string; + shape_type: 'polygon' | 'rectangle'; + label: string; + color: string; + fill_opacity: number; + stroke_width: number; + vertices: [number, number][]; + rotation: number; + sort_order: number; + created_by: string | null; + created_at: string; + updated_at: string; +} + +export interface MapLayerWithPins extends MapLayer { + pins: MapPin[]; + shapes: MapShape[]; +} + +// โ”€โ”€ Layers โ”€โ”€ + +export async function fetchMapLayers(supabase: SupabaseClient, departmentId: string): Promise { + const { data: layers, error: layerErr } = await (supabase as any) + .from('map_layers') + .select('*') + .eq('department_id', departmentId) + .order('sort_order'); + + if (layerErr) throw layerErr; + if (!layers || layers.length === 0) return []; + + const layerIds = layers.map((l: any) => l.id); + + const [pinResult, shapeResult] = await Promise.all([ + (supabase as any).from('map_pins').select('*').in('layer_id', layerIds).order('sort_order'), + (supabase as any).from('map_shapes').select('*').in('layer_id', layerIds).order('sort_order'), + ]); + + if (pinResult.error) throw pinResult.error; + if (shapeResult.error) throw shapeResult.error; + + const pins = pinResult.data ?? []; + const shapes = shapeResult.data ?? []; + + return layers.map((layer: any) => ({ + ...layer, + pins: pins.filter((p: any) => p.layer_id === layer.id), + shapes: shapes.filter((s: any) => s.layer_id === layer.id), + })); +} + +export async function createMapLayer( + supabase: SupabaseClient, + departmentId: string, + data: { + name: string; + layer_type: 'osm' | 'image'; + image_url?: string; + image_width?: number; + image_height?: number; + center_lat?: number; + center_lng?: number; + zoom_level?: number; + sort_order?: number; + }, +): Promise { + const { data: layer, error } = await (supabase as any) + .from('map_layers') + .insert({ + department_id: departmentId, + ...data, + }) + .select() + .single(); + + if (error) throw error; + return layer; +} + +export async function updateMapLayer( + supabase: SupabaseClient, + layerId: string, + data: Partial>, +): Promise { + const { data: layer, error } = await (supabase as any) + .from('map_layers') + .update({ ...data, updated_at: new Date().toISOString() }) + .eq('id', layerId) + .select() + .single(); + + if (error) throw error; + return layer; +} + +export async function deleteMapLayer(supabase: SupabaseClient, layerId: string): Promise { + const { error } = await (supabase as any) + .from('map_layers') + .delete() + .eq('id', layerId); + + if (error) throw error; +} + +// โ”€โ”€ Pins โ”€โ”€ + +export async function createMapPin( + supabase: SupabaseClient, + layerId: string, + data: { + label: string; + description?: string; + color?: string; + lat: number; + lng: number; + bounds_north?: number; + bounds_south?: number; + bounds_east?: number; + bounds_west?: number; + sort_order?: number; + }, +): Promise { + const { data: pin, error } = await (supabase as any) + .from('map_pins') + .insert({ + layer_id: layerId, + ...data, + }) + .select() + .single(); + + if (error) throw error; + return pin; +} + +export async function updateMapPin( + supabase: SupabaseClient, + pinId: string, + data: Partial>, +): Promise { + const { data: pin, error } = await (supabase as any) + .from('map_pins') + .update({ ...data, updated_at: new Date().toISOString() }) + .eq('id', pinId) + .select() + .single(); + + if (error) throw error; + return pin; +} + +export async function deleteMapPin(supabase: SupabaseClient, pinId: string): Promise { + const { error } = await (supabase as any) + .from('map_pins') + .delete() + .eq('id', pinId); + + if (error) throw error; +} + +// โ”€โ”€ Shapes โ”€โ”€ + +export async function createMapShape( + supabase: SupabaseClient, + layerId: string, + data: { + shape_type: 'polygon' | 'rectangle'; + label?: string; + color?: string; + fill_opacity?: number; + stroke_width?: number; + vertices: [number, number][]; + rotation?: number; + sort_order?: number; + }, +): Promise { + const { data: shape, error } = await (supabase as any) + .from('map_shapes') + .insert({ + layer_id: layerId, + ...data, + }) + .select() + .single(); + + if (error) throw error; + return shape; +} + +export async function updateMapShape( + supabase: SupabaseClient, + shapeId: string, + data: Partial>, +): Promise { + const { data: shape, error } = await (supabase as any) + .from('map_shapes') + .update({ ...data, updated_at: new Date().toISOString() }) + .eq('id', shapeId) + .select() + .single(); + + if (error) throw error; + return shape; +} + +export async function deleteMapShape(supabase: SupabaseClient, shapeId: string): Promise { + const { error } = await (supabase as any) + .from('map_shapes') + .delete() + .eq('id', shapeId); + + if (error) throw error; +} diff --git a/src/lib/api/org-contacts.test.ts b/src/lib/api/org-contacts.test.ts new file mode 100644 index 0000000..bc9ef8a --- /dev/null +++ b/src/lib/api/org-contacts.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchOrgContacts, + createOrgContact, + updateOrgContact, + deleteOrgContact, + fetchPinnedContacts, + pinContact, + unpinContact, +} from './org-contacts'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +function mockDeleteSupabase(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + for (const m of ['from', 'delete', 'eq']) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve(resolvedValue); + return { from: vi.fn(() => chain) } as any; +} + +// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('org-contacts API', () => { + describe('fetchOrgContacts', () => { + it('should return contacts ordered by name', async () => { + const contacts = [ + { id: 'c1', name: 'Alice', org_id: 'o1', category: 'vendor' }, + { id: 'c2', name: 'Bob', org_id: 'o1', category: 'general' }, + ]; + const supabase = mockSupabase({ data: contacts, error: null }); + + const result = await fetchOrgContacts(supabase, 'o1'); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('Alice'); + expect(result[1].name).toBe('Bob'); + }); + + it('should return empty array when data is null', async () => { + const supabase = mockSupabase({ data: null, error: null }); + + const result = await fetchOrgContacts(supabase, 'o1'); + expect(result).toEqual([]); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'fetch fail' } }); + + await expect(fetchOrgContacts(supabase, 'o1')).rejects.toEqual({ message: 'fetch fail' }); + }); + }); + + describe('createOrgContact', () => { + it('should create a contact with defaults', async () => { + const created = { id: 'c1', name: 'Test', org_id: 'o1', category: 'general', color: '#00A3E0' }; + const supabase = mockSupabase({ data: created, error: null }); + + const result = await createOrgContact(supabase, 'o1', { name: 'Test' }); + + expect(result.id).toBe('c1'); + expect(result.category).toBe('general'); + }); + + it('should create a contact with all fields', async () => { + const created = { + id: 'c2', name: 'Full', org_id: 'o1', role: 'Manager', company: 'Acme', + email: 'a@b.com', phone: '+1234', website: 'https://acme.com', + notes: 'VIP', category: 'sponsor', color: '#FF0000', + }; + const supabase = mockSupabase({ data: created, error: null }); + + const result = await createOrgContact(supabase, 'o1', { + name: 'Full', role: 'Manager', company: 'Acme', + email: 'a@b.com', phone: '+1234', website: 'https://acme.com', + notes: 'VIP', category: 'sponsor', color: '#FF0000', + }); + + expect(result.role).toBe('Manager'); + expect(result.company).toBe('Acme'); + expect(result.category).toBe('sponsor'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'create fail' } }); + + await expect(createOrgContact(supabase, 'o1', { name: 'X' })).rejects.toEqual({ message: 'create fail' }); + }); + }); + + describe('updateOrgContact', () => { + it('should update and return the contact', async () => { + const updated = { id: 'c1', name: 'Updated', org_id: 'o1', category: 'vendor' }; + const supabase = mockSupabase({ data: updated, error: null }); + + const result = await updateOrgContact(supabase, 'c1', { name: 'Updated', category: 'vendor' }); + + expect(result.name).toBe('Updated'); + expect(result.category).toBe('vendor'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'update fail' } }); + + await expect(updateOrgContact(supabase, 'c1', { name: 'X' })).rejects.toEqual({ message: 'update fail' }); + }); + }); + + describe('deleteOrgContact', () => { + it('should delete without error', async () => { + const supabase = mockDeleteSupabase({ data: null, error: null }); + + await expect(deleteOrgContact(supabase, 'c1')).resolves.toBeUndefined(); + }); + + it('should throw on error', async () => { + const supabase = mockDeleteSupabase({ data: null, error: { message: 'delete fail' } }); + + await expect(deleteOrgContact(supabase, 'c1')).rejects.toEqual({ message: 'delete fail' }); + }); + }); + + describe('fetchPinnedContacts', () => { + it('should return pinned contacts for a department', async () => { + const pins = [ + { id: 'p1', department_id: 'd1', contact_id: 'c1' }, + { id: 'p2', department_id: 'd1', contact_id: 'c2' }, + ]; + const supabase = mockSupabase({ data: pins, error: null }); + + const result = await fetchPinnedContacts(supabase, 'd1'); + + expect(result).toHaveLength(2); + expect(result[0].contact_id).toBe('c1'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'pin fetch fail' } }); + + await expect(fetchPinnedContacts(supabase, 'd1')).rejects.toEqual({ message: 'pin fetch fail' }); + }); + }); + + describe('pinContact', () => { + it('should pin a contact and return the record', async () => { + const pinned = { id: 'p1', department_id: 'd1', contact_id: 'c1' }; + const supabase = mockSupabase({ data: pinned, error: null }); + + const result = await pinContact(supabase, 'd1', 'c1'); + + expect(result.department_id).toBe('d1'); + expect(result.contact_id).toBe('c1'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'pin fail' } }); + + await expect(pinContact(supabase, 'd1', 'c1')).rejects.toEqual({ message: 'pin fail' }); + }); + }); + + describe('unpinContact', () => { + it('should unpin without error', async () => { + // unpinContact chains .delete().eq().eq() - no .single() + const chain: any = {}; + for (const m of ['from', 'delete', 'eq']) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: null }); + const supabase = { from: vi.fn(() => chain) } as any; + + await expect(unpinContact(supabase, 'd1', 'c1')).resolves.toBeUndefined(); + }); + + it('should throw on error', async () => { + const chain: any = {}; + for (const m of ['from', 'delete', 'eq']) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'unpin fail' } }); + const supabase = { from: vi.fn(() => chain) } as any; + + await expect(unpinContact(supabase, 'd1', 'c1')).rejects.toEqual({ message: 'unpin fail' }); + }); + }); +}); diff --git a/src/lib/api/org-contacts.ts b/src/lib/api/org-contacts.ts new file mode 100644 index 0000000..97eef98 --- /dev/null +++ b/src/lib/api/org-contacts.ts @@ -0,0 +1,158 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { OrgContact, DepartmentPinnedContact } from '$lib/supabase/types'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('api.org-contacts'); + +function db(supabase: SupabaseClient) { + return supabase as any; +} + +// ============================================================ +// Org Contacts CRUD +// ============================================================ + +export async function fetchOrgContacts( + supabase: SupabaseClient, + orgId: string +): Promise { + const { data, error } = await db(supabase) + .from('org_contacts') + .select('*') + .eq('org_id', orgId) + .order('name'); + + if (error) { + log.error('fetchOrgContacts failed', { error, data: { orgId } }); + throw error; + } + return (data ?? []) as OrgContact[]; +} + +export async function createOrgContact( + supabase: SupabaseClient, + orgId: string, + params: { + name: string; + role?: string; + company?: string; + email?: string; + phone?: string; + website?: string; + notes?: string; + category?: string; + color?: string; + } +): Promise { + const { data, error } = await db(supabase) + .from('org_contacts') + .insert({ + org_id: orgId, + name: params.name, + role: params.role ?? null, + company: params.company ?? null, + email: params.email ?? null, + phone: params.phone ?? null, + website: params.website ?? null, + notes: params.notes ?? null, + category: params.category ?? 'general', + color: params.color ?? '#00A3E0', + }) + .select() + .single(); + + if (error) { + log.error('createOrgContact failed', { error, data: { orgId, name: params.name } }); + throw error; + } + return data as OrgContact; +} + +export async function updateOrgContact( + supabase: SupabaseClient, + contactId: string, + params: Partial> +): Promise { + const { data, error } = await db(supabase) + .from('org_contacts') + .update({ ...params, updated_at: new Date().toISOString() }) + .eq('id', contactId) + .select() + .single(); + + if (error) { + log.error('updateOrgContact failed', { error, data: { contactId } }); + throw error; + } + return data as OrgContact; +} + +export async function deleteOrgContact( + supabase: SupabaseClient, + contactId: string +): Promise { + const { error } = await db(supabase) + .from('org_contacts') + .delete() + .eq('id', contactId); + + if (error) { + log.error('deleteOrgContact failed', { error, data: { contactId } }); + throw error; + } +} + +// ============================================================ +// Department Pinned Contacts +// ============================================================ + +export async function fetchPinnedContacts( + supabase: SupabaseClient, + departmentId: string +): Promise { + const { data, error } = await db(supabase) + .from('department_pinned_contacts') + .select('*') + .eq('department_id', departmentId); + + if (error) { + log.error('fetchPinnedContacts failed', { error, data: { departmentId } }); + throw error; + } + return (data ?? []) as DepartmentPinnedContact[]; +} + +export async function pinContact( + supabase: SupabaseClient, + departmentId: string, + contactId: string +): Promise { + const { data, error } = await db(supabase) + .from('department_pinned_contacts') + .insert({ department_id: departmentId, contact_id: contactId }) + .select() + .single(); + + if (error) { + log.error('pinContact failed', { error, data: { departmentId, contactId } }); + throw error; + } + return data as DepartmentPinnedContact; +} + +export async function unpinContact( + supabase: SupabaseClient, + departmentId: string, + contactId: string +): Promise { + const { error } = await db(supabase) + .from('department_pinned_contacts') + .delete() + .eq('department_id', departmentId) + .eq('contact_id', contactId); + + if (error) { + log.error('unpinContact failed', { error, data: { departmentId, contactId } }); + throw error; + } +} diff --git a/src/lib/api/organizations.test.ts b/src/lib/api/organizations.test.ts new file mode 100644 index 0000000..1ab0770 --- /dev/null +++ b/src/lib/api/organizations.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchUserOrganizations, + createOrganization, + updateOrganization, + deleteOrganization, + fetchOrgMembers, + inviteMember, + updateMemberRole, + removeMember, + generateSlug, +} from './organizations'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'not', 'order', 'single', 'in']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ generateSlug (pure function) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('generateSlug', () => { + it('lowercases and replaces spaces with hyphens', () => { + expect(generateSlug('My Organization')).toBe('my-organization'); + }); + + it('removes special characters', () => { + expect(generateSlug('Test & Co. (2024)')).toBe('test-co-2024'); + }); + + it('trims leading/trailing hyphens', () => { + expect(generateSlug('---hello---')).toBe('hello'); + }); + + it('truncates to 50 characters', () => { + const long = 'a'.repeat(100); + expect(generateSlug(long).length).toBeLessThanOrEqual(50); + }); + + it('handles empty string', () => { + expect(generateSlug('')).toBe(''); + }); + + it('collapses multiple hyphens', () => { + expect(generateSlug('hello world')).toBe('hello-world'); + }); +}); + +// โ”€โ”€ fetchUserOrganizations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchUserOrganizations', () => { + it('returns orgs with roles', async () => { + const data = [ + { role: 'admin', organizations: { id: 'o1', name: 'Org1', slug: 'org1' } }, + ]; + const sb = mockSupabase({ data, error: null }); + const result = await fetchUserOrganizations(sb); + expect(result).toHaveLength(1); + expect(result[0].role).toBe('admin'); + expect(result[0].id).toBe('o1'); + }); + + it('filters out null organizations', async () => { + const data = [ + { role: 'admin', organizations: { id: 'o1', name: 'Org1', slug: 'org1' } }, + { role: 'viewer', organizations: null }, + ]; + const sb = mockSupabase({ data, error: null }); + const result = await fetchUserOrganizations(sb); + expect(result).toHaveLength(1); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchUserOrganizations(sb)).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ createOrganization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('createOrganization', () => { + it('creates and returns org', async () => { + const org = { id: 'o1', name: 'New Org', slug: 'new-org' }; + const sb = mockSupabase({ data: org, error: null }); + expect(await createOrganization(sb, 'New Org', 'new-org')).toEqual(org); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'dup slug' } }); + await expect(createOrganization(sb, 'X', 'x')).rejects.toEqual({ message: 'dup slug' }); + }); +}); + +// โ”€โ”€ updateOrganization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('updateOrganization', () => { + it('updates and returns org', async () => { + const org = { id: 'o1', name: 'Updated' }; + const sb = mockSupabase({ data: org, error: null }); + expect(await updateOrganization(sb, 'o1', { name: 'Updated' })).toEqual(org); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateOrganization(sb, 'o1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ deleteOrganization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('deleteOrganization', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteOrganization(sb, 'o1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteOrganization(sb, 'o1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ fetchOrgMembers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchOrgMembers', () => { + it('returns members', async () => { + const members = [{ id: 'm1', user_id: 'u1', role: 'admin' }]; + const sb = mockSupabase({ data: members, error: null }); + expect(await fetchOrgMembers(sb, 'o1')).toEqual(members); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchOrgMembers(sb, 'o1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchOrgMembers(sb, 'o1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ inviteMember โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('inviteMember', () => { + it('throws when user not found', async () => { + const sb = mockSupabase({ data: null, error: { message: 'not found' } }); + await expect(inviteMember(sb, 'o1', 'nobody@test.com')).rejects.toThrow('User not found'); + }); + + it('throws when user is already a member', async () => { + const profile = { id: 'u1', email: 'a@b.com' }; + const existing = { id: 'om1' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: profile, error: null }); // profile lookup + if (singleIdx === 2) return Promise.resolve({ data: existing, error: null }); // already member + return Promise.resolve({ data: null, error: null }); + }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(inviteMember(sb, 'o1', 'a@b.com')).rejects.toThrow('already a member'); + }); + + it('invites successfully when user exists and is not a member', async () => { + const profile = { id: 'u1', email: 'a@b.com' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: profile, error: null }); // profile + if (singleIdx === 2) return Promise.resolve({ data: null, error: null }); // not a member + return Promise.resolve({ data: null, error: null }); + }); + chain.then = (resolve: any) => resolve({ data: null, error: null }); // insert success + const sb = { from: vi.fn(() => chain) } as any; + + await expect(inviteMember(sb, 'o1', 'a@b.com')).resolves.toBeUndefined(); + }); + + it('throws on insert error', async () => { + const profile = { id: 'u1', email: 'a@b.com' }; + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + let singleIdx = 0; + chain.single = vi.fn(() => { + singleIdx++; + if (singleIdx === 1) return Promise.resolve({ data: profile, error: null }); + if (singleIdx === 2) return Promise.resolve({ data: null, error: null }); + return Promise.resolve({ data: null, error: null }); + }); + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'insert fail' } }); + const sb = { from: vi.fn(() => chain) } as any; + + await expect(inviteMember(sb, 'o1', 'a@b.com')).rejects.toEqual({ message: 'insert fail' }); + }); +}); + +// โ”€โ”€ updateMemberRole โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('updateMemberRole', () => { + it('updates without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(updateMemberRole(sb, 'm1', 'editor')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateMemberRole(sb, 'm1', 'admin')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ removeMember โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('removeMember', () => { + it('removes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(removeMember(sb, 'm1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(removeMember(sb, 'm1')).rejects.toEqual({ message: 'fail' }); + }); +}); diff --git a/src/lib/api/schedule.test.ts b/src/lib/api/schedule.test.ts new file mode 100644 index 0000000..baff6f3 --- /dev/null +++ b/src/lib/api/schedule.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchStages, + createStage, + updateStage, + deleteStage, + fetchBlocks, + createBlock, + updateBlock, + deleteBlock, +} from './schedule'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ Stages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchStages', () => { + it('returns stages for a department', async () => { + const stages = [{ id: 's1', name: 'Main Stage', department_id: 'd1' }]; + const sb = mockSupabase({ data: stages, error: null }); + expect(await fetchStages(sb, 'd1')).toEqual(stages); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchStages(sb, 'd1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchStages(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createStage', () => { + it('creates with default color', async () => { + const stage = { id: 's1', name: 'VIP', color: '#6366f1' }; + const sb = mockSupabase({ data: stage, error: null }); + expect(await createStage(sb, 'd1', 'VIP')).toEqual(stage); + }); + + it('creates with custom color', async () => { + const stage = { id: 's2', name: 'Outdoor', color: '#00ff00' }; + const sb = mockSupabase({ data: stage, error: null }); + expect(await createStage(sb, 'd1', 'Outdoor', '#00ff00')).toEqual(stage); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createStage(sb, 'd1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateStage', () => { + it('updates and returns stage', async () => { + const stage = { id: 's1', name: 'Renamed' }; + const sb = mockSupabase({ data: stage, error: null }); + expect(await updateStage(sb, 's1', { name: 'Renamed' })).toEqual(stage); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateStage(sb, 's1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteStage', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteStage(sb, 's1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteStage(sb, 's1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Blocks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchBlocks', () => { + it('returns blocks for a department', async () => { + const blocks = [{ id: 'b1', title: 'Opening', department_id: 'd1' }]; + const sb = mockSupabase({ data: blocks, error: null }); + expect(await fetchBlocks(sb, 'd1')).toEqual(blocks); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchBlocks(sb, 'd1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchBlocks(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createBlock', () => { + it('creates a block with minimal params', async () => { + const block = { id: 'b1', title: 'Keynote', start_time: '09:00', end_time: '10:00' }; + const sb = mockSupabase({ data: block, error: null }); + const result = await createBlock(sb, 'd1', { + title: 'Keynote', + start_time: '09:00', + end_time: '10:00', + }); + expect(result).toEqual(block); + }); + + it('creates a block with all params', async () => { + const block = { id: 'b2', title: 'Panel', speaker: 'John' }; + const sb = mockSupabase({ data: block, error: null }); + const result = await createBlock(sb, 'd1', { + title: 'Panel', + start_time: '10:00', + end_time: '11:00', + stage_id: 's1', + description: 'A panel', + color: '#ff0000', + speaker: 'John', + }, 'user1'); + expect(result).toEqual(block); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createBlock(sb, 'd1', { title: 'X', start_time: '09:00', end_time: '10:00' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateBlock', () => { + it('updates and returns block', async () => { + const block = { id: 'b1', title: 'Updated' }; + const sb = mockSupabase({ data: block, error: null }); + expect(await updateBlock(sb, 'b1', { title: 'Updated' })).toEqual(block); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateBlock(sb, 'b1', { title: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteBlock', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteBlock(sb, 'b1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteBlock(sb, 'b1')).rejects.toEqual({ message: 'fail' }); + }); +}); diff --git a/src/lib/api/sponsor-allocations.test.ts b/src/lib/api/sponsor-allocations.test.ts new file mode 100644 index 0000000..994a884 --- /dev/null +++ b/src/lib/api/sponsor-allocations.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchEventSponsorAllocations, + fetchDepartmentSponsorAllocations, + createSponsorAllocation, + updateSponsorAllocation, + deleteSponsorAllocation, +} from './sponsor-allocations'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + // Terminal - resolve the promise + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + // For non-single queries, make the chain itself thenable + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('sponsor-allocations API', () => { + describe('fetchEventSponsorAllocations', () => { + it('should return allocations stripped of join data', async () => { + const raw = [ + { id: 'a1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 500, used_amount: 100, event_departments: { event_id: 'e1' } }, + { id: 'a2', sponsor_id: 's2', department_id: 'd2', allocated_amount: 300, used_amount: 0, event_departments: { event_id: 'e1' } }, + ]; + const supabase = mockSupabase({ data: raw, error: null }); + + const result = await fetchEventSponsorAllocations(supabase, 'e1'); + + expect(result).toHaveLength(2); + expect(result[0]).not.toHaveProperty('event_departments'); + expect(result[0].id).toBe('a1'); + expect(result[1].allocated_amount).toBe(300); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'fail' } }); + + await expect(fetchEventSponsorAllocations(supabase, 'e1')).rejects.toEqual({ message: 'fail' }); + }); + + it('should return empty array when data is null', async () => { + const supabase = mockSupabase({ data: null, error: null }); + + const result = await fetchEventSponsorAllocations(supabase, 'e1'); + expect(result).toEqual([]); + }); + }); + + describe('fetchDepartmentSponsorAllocations', () => { + it('should return allocations for a department', async () => { + const raw = [{ id: 'a1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 200, used_amount: 50 }]; + const supabase = mockSupabase({ data: raw, error: null }); + + const result = await fetchDepartmentSponsorAllocations(supabase, 'd1'); + + expect(result).toHaveLength(1); + expect(result[0].department_id).toBe('d1'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'dept fail' } }); + + await expect(fetchDepartmentSponsorAllocations(supabase, 'd1')).rejects.toEqual({ message: 'dept fail' }); + }); + }); + + describe('createSponsorAllocation', () => { + it('should create and return an allocation', async () => { + const created = { id: 'new1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 1000, used_amount: 0, notes: null }; + const supabase = mockSupabase({ data: created, error: null }); + + const result = await createSponsorAllocation(supabase, { + sponsor_id: 's1', + department_id: 'd1', + allocated_amount: 1000, + }); + + expect(result.id).toBe('new1'); + expect(result.allocated_amount).toBe(1000); + expect(result.used_amount).toBe(0); + }); + + it('should pass used_amount and notes when provided', async () => { + const created = { id: 'new2', sponsor_id: 's1', department_id: 'd1', allocated_amount: 500, used_amount: 100, notes: 'test' }; + const supabase = mockSupabase({ data: created, error: null }); + + const result = await createSponsorAllocation(supabase, { + sponsor_id: 's1', + department_id: 'd1', + allocated_amount: 500, + used_amount: 100, + notes: 'test', + }); + + expect(result.used_amount).toBe(100); + expect(result.notes).toBe('test'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'create fail' } }); + + await expect( + createSponsorAllocation(supabase, { sponsor_id: 's1', department_id: 'd1', allocated_amount: 100 }) + ).rejects.toEqual({ message: 'create fail' }); + }); + }); + + describe('updateSponsorAllocation', () => { + it('should update and return the allocation', async () => { + const updated = { id: 'a1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 750, used_amount: 200, notes: 'updated' }; + const supabase = mockSupabase({ data: updated, error: null }); + + const result = await updateSponsorAllocation(supabase, 'a1', { allocated_amount: 750, used_amount: 200, notes: 'updated' }); + + expect(result.allocated_amount).toBe(750); + expect(result.notes).toBe('updated'); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'update fail' } }); + + await expect(updateSponsorAllocation(supabase, 'a1', { allocated_amount: 100 })).rejects.toEqual({ message: 'update fail' }); + }); + }); + + describe('deleteSponsorAllocation', () => { + it('should delete without error', async () => { + // delete doesn't call .single(), so we need a different mock + const chain: any = {}; + const methods = ['from', 'delete', 'eq']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: null }); + const supabase = { from: vi.fn(() => chain) } as any; + + await expect(deleteSponsorAllocation(supabase, 'a1')).resolves.toBeUndefined(); + }); + + it('should throw on error', async () => { + const chain: any = {}; + const methods = ['from', 'delete', 'eq']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.then = (resolve: any) => resolve({ data: null, error: { message: 'delete fail' } }); + const supabase = { from: vi.fn(() => chain) } as any; + + await expect(deleteSponsorAllocation(supabase, 'a1')).rejects.toEqual({ message: 'delete fail' }); + }); + }); +}); diff --git a/src/lib/api/sponsor-allocations.ts b/src/lib/api/sponsor-allocations.ts new file mode 100644 index 0000000..83fa6f7 --- /dev/null +++ b/src/lib/api/sponsor-allocations.ts @@ -0,0 +1,115 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { SponsorAllocation } from '$lib/supabase/types'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('api.sponsor-allocations'); + +function db(supabase: SupabaseClient) { + return supabase as any; +} + +// ============================================================ +// Fetch allocations for an event (via departments) +// ============================================================ + +export async function fetchEventSponsorAllocations( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data, error } = await db(supabase) + .from('sponsor_allocations') + .select('*, event_departments!inner(event_id)') + .eq('event_departments.event_id', eventId); + + if (error) { + log.error('fetchEventSponsorAllocations failed', { error, data: { eventId } }); + throw error; + } + return (data ?? []).map((d: any) => { + const { event_departments, ...alloc } = d; + return alloc; + }) as SponsorAllocation[]; +} + +export async function fetchDepartmentSponsorAllocations( + supabase: SupabaseClient, + departmentId: string +): Promise { + const { data, error } = await db(supabase) + .from('sponsor_allocations') + .select('*') + .eq('department_id', departmentId); + + if (error) { + log.error('fetchDepartmentSponsorAllocations failed', { error, data: { departmentId } }); + throw error; + } + return (data ?? []) as SponsorAllocation[]; +} + +// ============================================================ +// CRUD +// ============================================================ + +export async function createSponsorAllocation( + supabase: SupabaseClient, + params: { + sponsor_id: string; + department_id: string; + allocated_amount: number; + used_amount?: number; + notes?: string; + } +): Promise { + const { data, error } = await db(supabase) + .from('sponsor_allocations') + .insert({ + sponsor_id: params.sponsor_id, + department_id: params.department_id, + allocated_amount: params.allocated_amount, + used_amount: params.used_amount ?? 0, + notes: params.notes ?? null, + }) + .select() + .single(); + + if (error) { + log.error('createSponsorAllocation failed', { error, data: params }); + throw error; + } + return data as SponsorAllocation; +} + +export async function updateSponsorAllocation( + supabase: SupabaseClient, + allocationId: string, + params: Partial> +): Promise { + const { data, error } = await db(supabase) + .from('sponsor_allocations') + .update({ ...params, updated_at: new Date().toISOString() }) + .eq('id', allocationId) + .select() + .single(); + + if (error) { + log.error('updateSponsorAllocation failed', { error, data: { allocationId } }); + throw error; + } + return data as SponsorAllocation; +} + +export async function deleteSponsorAllocation( + supabase: SupabaseClient, + allocationId: string +): Promise { + const { error } = await db(supabase) + .from('sponsor_allocations') + .delete() + .eq('id', allocationId); + + if (error) { + log.error('deleteSponsorAllocation failed', { error, data: { allocationId } }); + throw error; + } +} diff --git a/src/lib/api/sponsors-event.test.ts b/src/lib/api/sponsors-event.test.ts new file mode 100644 index 0000000..ebb0de1 --- /dev/null +++ b/src/lib/api/sponsors-event.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + fetchEventSponsorTiers, + fetchEventSponsors, + fetchEventDeliverables, +} from './sponsors'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('event-wide sponsor fetching', () => { + describe('fetchEventSponsorTiers', () => { + it('should return tiers stripped of join data', async () => { + const raw = [ + { id: 't1', name: 'Gold', sort_order: 0, event_departments: { event_id: 'e1' } }, + { id: 't2', name: 'Silver', sort_order: 1, event_departments: { event_id: 'e1' } }, + ]; + const supabase = mockSupabase({ data: raw, error: null }); + + const result = await fetchEventSponsorTiers(supabase, 'e1'); + + expect(result).toHaveLength(2); + expect(result[0]).not.toHaveProperty('event_departments'); + expect(result[0].name).toBe('Gold'); + expect(result[1].name).toBe('Silver'); + }); + + it('should return empty array when data is null', async () => { + const supabase = mockSupabase({ data: null, error: null }); + + const result = await fetchEventSponsorTiers(supabase, 'e1'); + expect(result).toEqual([]); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'tier fail' } }); + + await expect(fetchEventSponsorTiers(supabase, 'e1')).rejects.toEqual({ message: 'tier fail' }); + }); + }); + + describe('fetchEventSponsors', () => { + it('should return sponsors stripped of join data', async () => { + const raw = [ + { id: 's1', name: 'Acme', amount: 5000, status: 'confirmed', event_departments: { event_id: 'e1' } }, + { id: 's2', name: 'Beta', amount: 2000, status: 'prospect', event_departments: { event_id: 'e1' } }, + ]; + const supabase = mockSupabase({ data: raw, error: null }); + + const result = await fetchEventSponsors(supabase, 'e1'); + + expect(result).toHaveLength(2); + expect(result[0]).not.toHaveProperty('event_departments'); + expect(result[0].name).toBe('Acme'); + expect(result[0].amount).toBe(5000); + }); + + it('should return empty array when data is null', async () => { + const supabase = mockSupabase({ data: null, error: null }); + + const result = await fetchEventSponsors(supabase, 'e1'); + expect(result).toEqual([]); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'sponsor fail' } }); + + await expect(fetchEventSponsors(supabase, 'e1')).rejects.toEqual({ message: 'sponsor fail' }); + }); + }); + + describe('fetchEventDeliverables', () => { + it('should return deliverables stripped of join data', async () => { + const raw = [ + { id: 'd1', description: 'Logo placement', is_completed: false, sponsors: { department_id: 'dep1', event_departments: { event_id: 'e1' } } }, + ]; + const supabase = mockSupabase({ data: raw, error: null }); + + const result = await fetchEventDeliverables(supabase, 'e1'); + + expect(result).toHaveLength(1); + expect(result[0]).not.toHaveProperty('sponsors'); + expect(result[0].description).toBe('Logo placement'); + }); + + it('should return empty array when data is null', async () => { + const supabase = mockSupabase({ data: null, error: null }); + + const result = await fetchEventDeliverables(supabase, 'e1'); + expect(result).toEqual([]); + }); + + it('should throw on error', async () => { + const supabase = mockSupabase({ data: null, error: { message: 'deliverable fail' } }); + + await expect(fetchEventDeliverables(supabase, 'e1')).rejects.toEqual({ message: 'deliverable fail' }); + }); + }); +}); diff --git a/src/lib/api/sponsors.test.ts b/src/lib/api/sponsors.test.ts new file mode 100644 index 0000000..ca1d164 --- /dev/null +++ b/src/lib/api/sponsors.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + SPONSOR_STATUSES, + STATUS_LABELS, + STATUS_COLORS, + fetchSponsorTiers, + createSponsorTier, + updateSponsorTier, + deleteSponsorTier, + fetchSponsors, + createSponsor, + updateSponsor, + deleteSponsor, + fetchDeliverables, + fetchAllDeliverables, + createDeliverable, + updateDeliverable, + deleteDeliverable, +} from './sponsors'; + +// โ”€โ”€ Supabase mock builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function mockChain(resolvedValue: { data: any; error: any }) { + const chain: any = {}; + const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single']; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.single = vi.fn(() => Promise.resolve(resolvedValue)); + chain.then = (resolve: any) => resolve(resolvedValue); + return chain; +} + +function mockSupabase(resolvedValue: { data: any; error: any }) { + const chain = mockChain(resolvedValue); + return { from: vi.fn(() => chain), _chain: chain } as any; +} + +// โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('sponsor constants', () => { + it('SPONSOR_STATUSES has expected entries', () => { + expect(SPONSOR_STATUSES).toContain('prospect'); + expect(SPONSOR_STATUSES).toContain('confirmed'); + expect(SPONSOR_STATUSES).toContain('declined'); + expect(SPONSOR_STATUSES).toContain('active'); + expect(SPONSOR_STATUSES.length).toBe(5); + }); + + it('STATUS_LABELS has a label for every status', () => { + for (const s of SPONSOR_STATUSES) { + expect(STATUS_LABELS[s]).toBeDefined(); + expect(typeof STATUS_LABELS[s]).toBe('string'); + } + }); + + it('STATUS_COLORS has a color for every status', () => { + for (const s of SPONSOR_STATUSES) { + expect(STATUS_COLORS[s]).toBeDefined(); + expect(STATUS_COLORS[s]).toMatch(/^#/); + } + }); +}); + +// โ”€โ”€ Sponsor Tiers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchSponsorTiers', () => { + it('returns tiers for a department', async () => { + const tiers = [{ id: 't1', name: 'Gold', department_id: 'd1' }]; + const sb = mockSupabase({ data: tiers, error: null }); + expect(await fetchSponsorTiers(sb, 'd1')).toEqual(tiers); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchSponsorTiers(sb, 'd1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchSponsorTiers(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createSponsorTier', () => { + it('creates with default color', async () => { + const tier = { id: 't1', name: 'Silver', amount: 0, color: '#F59E0B' }; + const sb = mockSupabase({ data: tier, error: null }); + expect(await createSponsorTier(sb, 'd1', 'Silver')).toEqual(tier); + }); + + it('creates with custom amount and color', async () => { + const tier = { id: 't2', name: 'Platinum', amount: 10000, color: '#00ff00' }; + const sb = mockSupabase({ data: tier, error: null }); + expect(await createSponsorTier(sb, 'd1', 'Platinum', 10000, '#00ff00')).toEqual(tier); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createSponsorTier(sb, 'd1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateSponsorTier', () => { + it('updates and returns tier', async () => { + const tier = { id: 't1', name: 'Updated' }; + const sb = mockSupabase({ data: tier, error: null }); + expect(await updateSponsorTier(sb, 't1', { name: 'Updated' })).toEqual(tier); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateSponsorTier(sb, 't1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteSponsorTier', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteSponsorTier(sb, 't1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteSponsorTier(sb, 't1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Sponsors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchSponsors', () => { + it('returns sponsors for a department', async () => { + const sponsors = [{ id: 's1', name: 'Acme', department_id: 'd1' }]; + const sb = mockSupabase({ data: sponsors, error: null }); + expect(await fetchSponsors(sb, 'd1')).toEqual(sponsors); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchSponsors(sb, 'd1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchSponsors(sb, 'd1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createSponsor', () => { + it('creates with minimal params', async () => { + const sponsor = { id: 's1', name: 'Acme', status: 'prospect', amount: 0 }; + const sb = mockSupabase({ data: sponsor, error: null }); + expect(await createSponsor(sb, 'd1', { name: 'Acme' })).toEqual(sponsor); + }); + + it('creates with all params', async () => { + const sponsor = { id: 's2', name: 'BigCo', status: 'confirmed', amount: 5000 }; + const sb = mockSupabase({ data: sponsor, error: null }); + expect(await createSponsor(sb, 'd1', { + name: 'BigCo', + tier_id: 't1', + contact_name: 'John', + contact_email: 'john@bigco.com', + contact_phone: '+1234', + website: 'https://bigco.com', + logo_url: 'https://bigco.com/logo.png', + status: 'confirmed', + amount: 5000, + notes: 'VIP sponsor', + })).toEqual(sponsor); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createSponsor(sb, 'd1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateSponsor', () => { + it('updates and returns sponsor', async () => { + const sponsor = { id: 's1', name: 'Updated' }; + const sb = mockSupabase({ data: sponsor, error: null }); + expect(await updateSponsor(sb, 's1', { name: 'Updated' })).toEqual(sponsor); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateSponsor(sb, 's1', { name: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteSponsor', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteSponsor(sb, 's1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteSponsor(sb, 's1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +// โ”€โ”€ Deliverables โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('fetchDeliverables', () => { + it('returns deliverables for a sponsor', async () => { + const delivs = [{ id: 'dl1', description: 'Logo placement', sponsor_id: 's1' }]; + const sb = mockSupabase({ data: delivs, error: null }); + expect(await fetchDeliverables(sb, 's1')).toEqual(delivs); + }); + + it('returns empty array when null', async () => { + const sb = mockSupabase({ data: null, error: null }); + expect(await fetchDeliverables(sb, 's1')).toEqual([]); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchDeliverables(sb, 's1')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('fetchAllDeliverables', () => { + it('returns empty array for empty sponsor list', async () => { + const sb = mockSupabase({ data: [], error: null }); + expect(await fetchAllDeliverables(sb, [])).toEqual([]); + }); + + it('returns deliverables for multiple sponsors', async () => { + const delivs = [ + { id: 'dl1', sponsor_id: 's1' }, + { id: 'dl2', sponsor_id: 's2' }, + ]; + const sb = mockSupabase({ data: delivs, error: null }); + expect(await fetchAllDeliverables(sb, ['s1', 's2'])).toEqual(delivs); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(fetchAllDeliverables(sb, ['s1'])).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('createDeliverable', () => { + it('creates with description only', async () => { + const deliv = { id: 'dl1', description: 'Banner', sponsor_id: 's1' }; + const sb = mockSupabase({ data: deliv, error: null }); + expect(await createDeliverable(sb, 's1', 'Banner')).toEqual(deliv); + }); + + it('creates with due date', async () => { + const deliv = { id: 'dl2', description: 'Video', sponsor_id: 's1', due_date: '2025-06-01' }; + const sb = mockSupabase({ data: deliv, error: null }); + expect(await createDeliverable(sb, 's1', 'Video', '2025-06-01')).toEqual(deliv); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(createDeliverable(sb, 's1', 'X')).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('updateDeliverable', () => { + it('updates and returns deliverable', async () => { + const deliv = { id: 'dl1', description: 'Updated' }; + const sb = mockSupabase({ data: deliv, error: null }); + expect(await updateDeliverable(sb, 'dl1', { description: 'Updated' })).toEqual(deliv); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(updateDeliverable(sb, 'dl1', { description: 'X' })).rejects.toEqual({ message: 'fail' }); + }); +}); + +describe('deleteDeliverable', () => { + it('deletes without error', async () => { + const sb = mockSupabase({ data: null, error: null }); + await expect(deleteDeliverable(sb, 'dl1')).resolves.toBeUndefined(); + }); + + it('throws on error', async () => { + const sb = mockSupabase({ data: null, error: { message: 'fail' } }); + await expect(deleteDeliverable(sb, 'dl1')).rejects.toEqual({ message: 'fail' }); + }); +}); diff --git a/src/lib/api/sponsors.ts b/src/lib/api/sponsors.ts index 7a2c6a9..cf7f4e5 100644 --- a/src/lib/api/sponsors.ts +++ b/src/lib/api/sponsors.ts @@ -108,6 +108,70 @@ export async function deleteSponsorTier( } } +// ============================================================ +// Event-wide Tier + Sponsor fetching +// ============================================================ + +export async function fetchEventSponsorTiers( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data, error } = await db(supabase) + .from('sponsor_tiers') + .select('*, event_departments!inner(event_id)') + .eq('event_departments.event_id', eventId) + .order('sort_order'); + + if (error) { + log.error('fetchEventSponsorTiers failed', { error, data: { eventId } }); + throw error; + } + return (data ?? []).map((d: any) => { + const { event_departments, ...tier } = d; + return tier; + }) as SponsorTier[]; +} + +export async function fetchEventSponsors( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data, error } = await db(supabase) + .from('sponsors') + .select('*, event_departments!inner(event_id)') + .eq('event_departments.event_id', eventId) + .order('name'); + + if (error) { + log.error('fetchEventSponsors failed', { error, data: { eventId } }); + throw error; + } + return (data ?? []).map((d: any) => { + const { event_departments, ...sponsor } = d; + return sponsor; + }) as Sponsor[]; +} + +export async function fetchEventDeliverables( + supabase: SupabaseClient, + eventId: string +): Promise { + const { data, error } = await db(supabase) + .from('sponsor_deliverables') + .select('*, sponsors!inner(department_id, event_departments!inner(event_id))') + .eq('sponsors.event_departments.event_id', eventId) + .order('sort_order'); + + if (error) { + log.error('fetchEventDeliverables failed', { error, data: { eventId } }); + throw error; + } + return (data ?? []).map((d: any) => { + const { sponsors, ...del } = d; + return del; + }) as SponsorDeliverable[]; +} + // ============================================================ // Sponsors // ============================================================ diff --git a/src/lib/components/documents/FileBrowser.svelte b/src/lib/components/documents/FileBrowser.svelte index fae3729..25286a5 100644 --- a/src/lib/components/documents/FileBrowser.svelte +++ b/src/lib/components/documents/FileBrowser.svelte @@ -1,11 +1,7 @@ + + +
-
+
+ + {#if fileDraggingOver} +
+
+ cloud_upload +

+ Drop files to upload +

+
+
+ {/if} + -
+
- + {#if fileUploading} +
+ progress_activity + {fileUploadProgress} +
+ {/if} + + {/each} {/if} diff --git a/src/lib/components/kanban/CardDetailModal.svelte b/src/lib/components/kanban/CardDetailModal.svelte index add8451..c35b54c 100644 --- a/src/lib/components/kanban/CardDetailModal.svelte +++ b/src/lib/components/kanban/CardDetailModal.svelte @@ -12,6 +12,7 @@ import type { KanbanCard } from "$lib/supabase/types"; import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "$lib/supabase/types"; + import { toasts } from "$lib/stores/toast.svelte"; let isMounted = $state(true); onDestroy(() => { @@ -304,6 +305,9 @@ due_date: dueDate || null, priority, } as KanbanCard); + toasts.success("Changes saved"); + } else { + toasts.error("Failed to save changes"); } isSaving = false; } @@ -338,7 +342,12 @@ .select() .single(); - if (!error && newCard) { + if (error) { + toasts.error("Failed to create card"); + isSaving = false; + return; + } + if (newCard) { // Persist checklist items added during creation if (checklist.length > 0) { await supabase.from("kanban_checklist_items").insert( @@ -359,6 +368,7 @@ })), ); } + toasts.success("Card created"); onCreate?.(newCard as KanbanCard); onClose(); } @@ -611,81 +621,122 @@ onclick={createTag} disabled={!newTagName.trim()} > - + Add + Add
{/if} - +
- {#each orgTags as tag} - - {/each} - {#if !showTagManager} - {#if showTagInput} -
- - e.key === "Enter" && createTag()} - /> - - + {#if showTagInput} + + +
{ showTagInput = false; newTagName = ""; }} + >
+
- Cancel - -
- {:else} - - {/if} + {#each orgTags.filter((t) => !cardTagIds.has(t.id)) as tag} + + {/each} + {#if orgTags.filter((t) => !cardTagIds.has(t.id)).length === 0} +

+ All tags added +

+ {/if} +
+
+ + e.key === "Enter" && + createTag()} + /> + +
+
+
+ {/if} +
{/if}
- -
- (assigneeId = id)} - /> + + (assigneeId = id)} + /> + +
+ + +
+ ({ + value: cat.id, + label: cat.name, + }))} + />
-
- - -
-
- - -
+ +
-
-
- - -
-
- - -
-
- -
- - -
+ +
+ + + +
+ + + + -
+ -
+ {#if error} -

{error}

+

{error}

{:else if hint} -

{hint}

+

{hint}

{/if}
diff --git a/src/lib/components/ui/index.ts b/src/lib/components/ui/index.ts index 305a794..6639fc4 100644 --- a/src/lib/components/ui/index.ts +++ b/src/lib/components/ui/index.ts @@ -41,3 +41,4 @@ export { default as ImagePreviewModal } from './ImagePreviewModal.svelte'; export { default as Twemoji } from './Twemoji.svelte'; export { default as EmojiPicker } from './EmojiPicker.svelte'; export { default as VirtualList } from './VirtualList.svelte'; +export { default as PersonContactModal } from './PersonContactModal.svelte'; diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index 92c1c9f..5b261c2 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -1,4 +1,4 @@ -๏ปฟexport type Json = +export type Json = | string | number | boolean @@ -65,6 +65,111 @@ export type Database = { }, ] } + budget_categories: { + Row: { + color: string | null + created_at: string | null + department_id: string + id: string + name: string + sort_order: number + } + Insert: { + color?: string | null + created_at?: string | null + department_id: string + id?: string + name: string + sort_order?: number + } + Update: { + color?: string | null + created_at?: string | null + department_id?: string + id?: string + name?: string + sort_order?: number + } + Relationships: [ + { + foreignKeyName: "budget_categories_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + ] + } + budget_items: { + Row: { + actual_amount: number + category_id: string | null + created_at: string | null + created_by: string | null + department_id: string + description: string + id: string + item_type: string + notes: string | null + planned_amount: number + receipt_document_id: string | null + sort_order: number + updated_at: string | null + } + Insert: { + actual_amount?: number + category_id?: string | null + created_at?: string | null + created_by?: string | null + department_id: string + description: string + id?: string + item_type?: string + notes?: string | null + planned_amount?: number + receipt_document_id?: string | null + sort_order?: number + updated_at?: string | null + } + Update: { + actual_amount?: number + category_id?: string | null + created_at?: string | null + created_by?: string | null + department_id?: string + description?: string + id?: string + item_type?: string + notes?: string | null + planned_amount?: number + receipt_document_id?: string | null + sort_order?: number + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "budget_items_category_id_fkey" + columns: ["category_id"] + isOneToOne: false + referencedRelation: "budget_categories" + referencedColumns: ["id"] + }, + { + foreignKeyName: "budget_items_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + { + foreignKeyName: "budget_items_receipt_document_id_fkey" + columns: ["receipt_document_id"] + isOneToOne: false + referencedRelation: "documents" + referencedColumns: ["id"] + }, + ] + } calendar_events: { Row: { all_day: boolean | null @@ -362,6 +467,65 @@ export type Database = { }, ] } + department_contacts: { + Row: { + category: string | null + color: string | null + company: string | null + created_at: string + created_by: string | null + department_id: string + email: string | null + id: string + name: string + notes: string | null + phone: string | null + role: string | null + updated_at: string + website: string | null + } + Insert: { + category?: string | null + color?: string | null + company?: string | null + created_at?: string + created_by?: string | null + department_id: string + email?: string | null + id?: string + name: string + notes?: string | null + phone?: string | null + role?: string | null + updated_at?: string + website?: string | null + } + Update: { + category?: string | null + color?: string | null + company?: string | null + created_at?: string + created_by?: string | null + department_id?: string + email?: string | null + id?: string + name?: string + notes?: string | null + phone?: string | null + role?: string | null + updated_at?: string + website?: string | null + } + Relationships: [ + { + foreignKeyName: "department_contacts_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + ] + } department_dashboards: { Row: { created_at: string | null @@ -438,6 +602,42 @@ export type Database = { }, ] } + department_pinned_contacts: { + Row: { + contact_id: string + department_id: string + id: string + pinned_at: string + } + Insert: { + contact_id: string + department_id: string + id?: string + pinned_at?: string + } + Update: { + contact_id?: string + department_id?: string + id?: string + pinned_at?: string + } + Relationships: [ + { + foreignKeyName: "department_pinned_contacts_contact_id_fkey" + columns: ["contact_id"] + isOneToOne: false + referencedRelation: "org_contacts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "department_pinned_contacts_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + ] + } document_locks: { Row: { document_id: string @@ -475,6 +675,8 @@ export type Database = { content: Json | null created_at: string | null created_by: string | null + department_id: string | null + event_id: string | null id: string name: string org_id: string | null @@ -488,6 +690,8 @@ export type Database = { content?: Json | null created_at?: string | null created_by?: string | null + department_id?: string | null + event_id?: string | null id?: string name: string org_id?: string | null @@ -501,6 +705,8 @@ export type Database = { content?: Json | null created_at?: string | null created_by?: string | null + department_id?: string | null + event_id?: string | null id?: string name?: string org_id?: string | null @@ -511,6 +717,20 @@ export type Database = { updated_at?: string | null } Relationships: [ + { + foreignKeyName: "documents_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + { + foreignKeyName: "documents_event_id_fkey" + columns: ["event_id"] + isOneToOne: false + referencedRelation: "events" + referencedColumns: ["id"] + }, { foreignKeyName: "documents_org_id_fkey" columns: ["org_id"] @@ -562,6 +782,7 @@ export type Database = { event_id: string id: string name: string + planned_budget: number sort_order: number } Insert: { @@ -572,6 +793,7 @@ export type Database = { event_id: string id?: string name: string + planned_budget?: number sort_order?: number } Update: { @@ -582,6 +804,7 @@ export type Database = { event_id?: string id?: string name?: string + planned_budget?: number sort_order?: number } Relationships: [ @@ -1170,6 +1393,65 @@ export type Database = { }, ] } + org_contacts: { + Row: { + category: string | null + color: string | null + company: string | null + created_at: string + created_by: string | null + email: string | null + id: string + name: string + notes: string | null + org_id: string + phone: string | null + role: string | null + updated_at: string + website: string | null + } + Insert: { + category?: string | null + color?: string | null + company?: string | null + created_at?: string + created_by?: string | null + email?: string | null + id?: string + name: string + notes?: string | null + org_id: string + phone?: string | null + role?: string | null + updated_at?: string + website?: string | null + } + Update: { + category?: string | null + color?: string | null + company?: string | null + created_at?: string + created_by?: string | null + email?: string | null + id?: string + name?: string + notes?: string | null + org_id?: string + phone?: string | null + role?: string | null + updated_at?: string + website?: string | null + } + Relationships: [ + { + foreignKeyName: "org_contacts_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } org_google_calendars: { Row: { calendar_id: string @@ -1365,35 +1647,80 @@ export type Database = { Row: { avatar_url: string | null created_at: string | null + currency: string + date_format: string + default_calendar_view: string + default_dept_layout: string + default_dept_modules: string[] + default_event_color: string + default_event_status: string + description: string | null + feature_budget: boolean + feature_chat: boolean + feature_contacts: boolean + feature_sponsors: boolean icon_url: string | null id: string matrix_space_id: string | null name: string slug: string + social_links: Json | null theme_color: string | null + timezone: string updated_at: string | null + week_start_day: string } Insert: { avatar_url?: string | null created_at?: string | null + currency?: string + date_format?: string + default_calendar_view?: string + default_dept_layout?: string + default_dept_modules?: string[] + default_event_color?: string + default_event_status?: string + description?: string | null + feature_budget?: boolean + feature_chat?: boolean + feature_contacts?: boolean + feature_sponsors?: boolean icon_url?: string | null id?: string matrix_space_id?: string | null name: string slug: string + social_links?: Json | null theme_color?: string | null + timezone?: string updated_at?: string | null + week_start_day?: string } Update: { avatar_url?: string | null created_at?: string | null + currency?: string + date_format?: string + default_calendar_view?: string + default_dept_layout?: string + default_dept_modules?: string[] + default_event_color?: string + default_event_status?: string + description?: string | null + feature_budget?: boolean + feature_chat?: boolean + feature_contacts?: boolean + feature_sponsors?: boolean icon_url?: string | null id?: string matrix_space_id?: string | null name?: string slug?: string + social_links?: Json | null theme_color?: string | null + timezone?: string updated_at?: string | null + week_start_day?: string } Relationships: [] } @@ -1406,6 +1733,7 @@ export type Database = { full_name: string | null hoodie_size: string | null id: string + is_platform_admin: boolean phone: string | null shirt_size: string | null updated_at: string | null @@ -1418,6 +1746,7 @@ export type Database = { full_name?: string | null hoodie_size?: string | null id: string + is_platform_admin?: boolean phone?: string | null shirt_size?: string | null updated_at?: string | null @@ -1430,12 +1759,310 @@ export type Database = { full_name?: string | null hoodie_size?: string | null id?: string + is_platform_admin?: boolean phone?: string | null shirt_size?: string | null updated_at?: string | null } Relationships: [] } + schedule_blocks: { + Row: { + color: string | null + created_at: string + created_by: string | null + department_id: string + description: string | null + end_time: string + id: string + sort_order: number + speaker: string | null + stage_id: string | null + start_time: string + title: string + updated_at: string + } + Insert: { + color?: string | null + created_at?: string + created_by?: string | null + department_id: string + description?: string | null + end_time: string + id?: string + sort_order?: number + speaker?: string | null + stage_id?: string | null + start_time: string + title: string + updated_at?: string + } + Update: { + color?: string | null + created_at?: string + created_by?: string | null + department_id?: string + description?: string | null + end_time?: string + id?: string + sort_order?: number + speaker?: string | null + stage_id?: string | null + start_time?: string + title?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "schedule_blocks_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + { + foreignKeyName: "schedule_blocks_stage_id_fkey" + columns: ["stage_id"] + isOneToOne: false + referencedRelation: "schedule_stages" + referencedColumns: ["id"] + }, + ] + } + schedule_stages: { + Row: { + color: string | null + created_at: string + department_id: string + id: string + name: string + sort_order: number + } + Insert: { + color?: string | null + created_at?: string + department_id: string + id?: string + name: string + sort_order?: number + } + Update: { + color?: string | null + created_at?: string + department_id?: string + id?: string + name?: string + sort_order?: number + } + Relationships: [ + { + foreignKeyName: "schedule_stages_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + ] + } + sponsor_allocations: { + Row: { + allocated_amount: number + created_at: string + created_by: string | null + department_id: string + id: string + notes: string | null + sponsor_id: string + updated_at: string + used_amount: number + } + Insert: { + allocated_amount?: number + created_at?: string + created_by?: string | null + department_id: string + id?: string + notes?: string | null + sponsor_id: string + updated_at?: string + used_amount?: number + } + Update: { + allocated_amount?: number + created_at?: string + created_by?: string | null + department_id?: string + id?: string + notes?: string | null + sponsor_id?: string + updated_at?: string + used_amount?: number + } + Relationships: [ + { + foreignKeyName: "sponsor_allocations_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + { + foreignKeyName: "sponsor_allocations_sponsor_id_fkey" + columns: ["sponsor_id"] + isOneToOne: false + referencedRelation: "sponsors" + referencedColumns: ["id"] + }, + ] + } + sponsor_deliverables: { + Row: { + created_at: string | null + description: string + due_date: string | null + id: string + is_completed: boolean + sort_order: number + sponsor_id: string + updated_at: string | null + } + Insert: { + created_at?: string | null + description: string + due_date?: string | null + id?: string + is_completed?: boolean + sort_order?: number + sponsor_id: string + updated_at?: string | null + } + Update: { + created_at?: string | null + description?: string + due_date?: string | null + id?: string + is_completed?: boolean + sort_order?: number + sponsor_id?: string + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "sponsor_deliverables_sponsor_id_fkey" + columns: ["sponsor_id"] + isOneToOne: false + referencedRelation: "sponsors" + referencedColumns: ["id"] + }, + ] + } + sponsor_tiers: { + Row: { + amount: number | null + color: string | null + created_at: string | null + department_id: string + id: string + name: string + sort_order: number + } + Insert: { + amount?: number | null + color?: string | null + created_at?: string | null + department_id: string + id?: string + name: string + sort_order?: number + } + Update: { + amount?: number | null + color?: string | null + created_at?: string | null + department_id?: string + id?: string + name?: string + sort_order?: number + } + Relationships: [ + { + foreignKeyName: "sponsor_tiers_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + ] + } + sponsors: { + Row: { + amount: number | null + contact_email: string | null + contact_name: string | null + contact_phone: string | null + created_at: string | null + created_by: string | null + department_id: string + id: string + logo_url: string | null + name: string + notes: string | null + status: string + tier_id: string | null + updated_at: string | null + website: string | null + } + Insert: { + amount?: number | null + contact_email?: string | null + contact_name?: string | null + contact_phone?: string | null + created_at?: string | null + created_by?: string | null + department_id: string + id?: string + logo_url?: string | null + name: string + notes?: string | null + status?: string + tier_id?: string | null + updated_at?: string | null + website?: string | null + } + Update: { + amount?: number | null + contact_email?: string | null + contact_name?: string | null + contact_phone?: string | null + created_at?: string | null + created_by?: string | null + department_id?: string + id?: string + logo_url?: string | null + name?: string + notes?: string | null + status?: string + tier_id?: string | null + updated_at?: string | null + website?: string | null + } + Relationships: [ + { + foreignKeyName: "sponsors_department_id_fkey" + columns: ["department_id"] + isOneToOne: false + referencedRelation: "event_departments" + referencedColumns: ["id"] + }, + { + foreignKeyName: "sponsors_tier_id_fkey" + columns: ["tier_id"] + isOneToOne: false + referencedRelation: "sponsor_tiers" + referencedColumns: ["id"] + }, + ] + } tags: { Row: { color: string | null @@ -1589,14 +2216,28 @@ export type Database = { } Functions: { compute_document_path: { Args: { doc_id: string }; Returns: string } - get_next_document_position: { - Args: { folder_id: string } - Returns: number + create_default_org_roles: { + Args: { p_org_id: string } + Returns: undefined } + get_next_document_position: + | { Args: { folder_id: string }; Returns: number } + | { Args: { p_org_id: string; p_parent_id: string }; Returns: number } is_org_member: { Args: { org_id: string }; Returns: boolean } + is_platform_admin: { Args: never; Returns: boolean } + seed_event_task_columns: { + Args: { p_event_id: string } + Returns: undefined + } } Enums: { - layout_preset: "single" | "split" | "grid" | "focus_sidebar" | "custom" + layout_preset: + | "single" + | "split" + | "grid" + | "focus_sidebar" + | "custom" + | "triple" module_type: | "kanban" | "files" @@ -1604,6 +2245,9 @@ export type Database = { | "notes" | "schedule" | "contacts" + | "budget" + | "sponsors" + | "map" } CompositeTypes: { [_ in never]: never @@ -1731,7 +2375,14 @@ export type CompositeTypes< export const Constants = { public: { Enums: { - layout_preset: ["single", "split", "grid", "focus_sidebar", "custom"], + layout_preset: [ + "single", + "split", + "grid", + "focus_sidebar", + "custom", + "triple", + ], module_type: [ "kanban", "files", @@ -1739,138 +2390,68 @@ export const Constants = { "notes", "schedule", "contacts", + "budget", + "sponsors", + "map", ], }, }, } as const -// ============================================================ -// Convenience type aliases used throughout the codebase -// ============================================================ -export type Profile = Tables<'profiles'>; -export type Organization = Tables<'organizations'>; -export type Document = Tables<'documents'>; -export type KanbanBoard = Tables<'kanban_boards'>; -export type KanbanColumn = Tables<'kanban_columns'>; -export type KanbanCard = Tables<'kanban_cards'>; -export type CalendarEvent = Tables<'calendar_events'>; -export type OrgRole = Tables<'org_roles'>; -export type MemberRole = string; -export type EventTaskColumn = Tables<'event_task_columns'>; -export type EventTask = Tables<'event_tasks'>; -export type DepartmentDashboard = Tables<'department_dashboards'>; -export type DashboardPanel = Tables<'dashboard_panels'>; -export type DepartmentChecklist = Tables<'department_checklists'>; -export type DepartmentChecklistItem = Tables<'department_checklist_items'>; -export type DepartmentNote = Tables<'department_notes'>; +// โ”€โ”€ Convenience type aliases โ”€โ”€ +type TableRow = Database['public']['Tables'][T]['Row']; + +export type Profile = TableRow<'profiles'>; +export type Organization = TableRow<'organizations'>; +export type Document = TableRow<'documents'>; +export type KanbanBoard = TableRow<'kanban_boards'>; +export type KanbanColumn = TableRow<'kanban_columns'>; +export type KanbanCard = TableRow<'kanban_cards'>; +export type KanbanComment = TableRow<'kanban_comments'>; +export type KanbanLabel = TableRow<'kanban_labels'>; +export type CardLabel = TableRow<'card_labels'>; +export type CardTag = TableRow<'card_tags'>; +export type ChecklistItem = TableRow<'checklist_items'>; +export type CardAssignee = TableRow<'card_assignees'>; +export type CalendarEvent = TableRow<'calendar_events'>; +export type OrgRole = TableRow<'org_roles'>; +export type OrgMember = TableRow<'org_members'>; +export type OrgInvite = TableRow<'org_invites'>; +export type OrgContact = TableRow<'org_contacts'>; +export type OrgGoogleCalendar = TableRow<'org_google_calendars'>; +export type Tag = TableRow<'tags'>; +export type ActivityLog = TableRow<'activity_log'>; +export type DocumentLock = TableRow<'document_locks'>; +export type MatrixCredential = TableRow<'matrix_credentials'>; +export type UserPreference = TableRow<'user_preferences'>; +export type Team = TableRow<'teams'>; +export type TeamMember = TableRow<'team_members'>; +export type Event = TableRow<'events'>; +export type EventMember = TableRow<'event_members'>; +export type EventRole = TableRow<'event_roles'>; +export type EventDepartment = TableRow<'event_departments'>; +export type EventMemberDepartment = TableRow<'event_member_departments'>; +export type EventAttendee = TableRow<'event_attendees'>; +export type EventTaskColumn = TableRow<'event_task_columns'>; +export type EventTask = TableRow<'event_tasks'>; +export type DepartmentDashboard = TableRow<'department_dashboards'>; +export type DashboardPanel = TableRow<'dashboard_panels'>; +export type DepartmentChecklist = TableRow<'department_checklists'>; +export type DepartmentChecklistItem = TableRow<'department_checklist_items'>; +export type DepartmentNote = TableRow<'department_notes'>; +export type DepartmentContact = TableRow<'department_contacts'>; +export type DepartmentPinnedContact = TableRow<'department_pinned_contacts'>; +export type ScheduleStage = TableRow<'schedule_stages'>; +export type ScheduleBlock = TableRow<'schedule_blocks'>; +export type BudgetCategory = TableRow<'budget_categories'>; +export type BudgetItem = TableRow<'budget_items'>; +export type SponsorTier = TableRow<'sponsor_tiers'>; +export type Sponsor = TableRow<'sponsors'>; +export type SponsorDeliverable = TableRow<'sponsor_deliverables'>; +export type SponsorAllocation = TableRow<'sponsor_allocations'>; +export type KanbanChecklistItem = TableRow<'kanban_checklist_items'>; + export type ModuleType = Database['public']['Enums']['module_type']; export type LayoutPreset = Database['public']['Enums']['layout_preset']; -// Schedule/Timeline types (migration 028 โ€” use db() cast until types regenerated) -export interface ScheduleStage { - id: string; - department_id: string; - name: string; - color: string | null; - sort_order: number; - created_at: string; -} - -export interface ScheduleBlock { - id: string; - department_id: string; - stage_id: string | null; - title: string; - description: string | null; - start_time: string; - end_time: string; - color: string | null; - speaker: string | null; - sort_order: number; - created_by: string | null; - created_at: string; - updated_at: string; -} - -// Budget/Finance types (migration 029 โ€” use db() cast until types regenerated) -export interface BudgetCategory { - id: string; - department_id: string; - name: string; - color: string | null; - sort_order: number; - created_at: string; -} - -export interface BudgetItem { - id: string; - department_id: string; - category_id: string | null; - description: string; - item_type: 'income' | 'expense'; - planned_amount: number; - actual_amount: number; - notes: string | null; - sort_order: number; - created_by: string | null; - created_at: string; - updated_at: string; -} - -// Sponsors & Partners types (migration 029) -export interface SponsorTier { - id: string; - department_id: string; - name: string; - amount: number; - color: string | null; - sort_order: number; - created_at: string; -} - -export interface Sponsor { - id: string; - department_id: string; - tier_id: string | null; - name: string; - contact_name: string | null; - contact_email: string | null; - contact_phone: string | null; - website: string | null; - logo_url: string | null; - status: 'prospect' | 'contacted' | 'confirmed' | 'declined' | 'active'; - amount: number; - notes: string | null; - created_by: string | null; - created_at: string; - updated_at: string; -} - -export interface SponsorDeliverable { - id: string; - sponsor_id: string; - description: string; - is_completed: boolean; - due_date: string | null; - sort_order: number; - created_at: string; - updated_at: string; -} - -// Contacts/Vendor Directory types (migration 028) -export interface DepartmentContact { - id: string; - department_id: string; - name: string; - role: string | null; - company: string | null; - email: string | null; - phone: string | null; - website: string | null; - notes: string | null; - category: string; - color: string | null; - created_by: string | null; - created_at: string; - updated_at: string; -} +export type MemberRole = string; diff --git a/src/lib/types/dom-to-image-more.d.ts b/src/lib/types/dom-to-image-more.d.ts new file mode 100644 index 0000000..6ab18ad --- /dev/null +++ b/src/lib/types/dom-to-image-more.d.ts @@ -0,0 +1,10 @@ +declare module 'dom-to-image-more' { + const domtoimage: { + toBlob(node: HTMLElement, options?: Record): Promise; + toPng(node: HTMLElement, options?: Record): Promise; + toJpeg(node: HTMLElement, options?: Record): Promise; + toSvg(node: HTMLElement, options?: Record): Promise; + toPixelData(node: HTMLElement, options?: Record): Promise; + }; + export default domtoimage; +} diff --git a/src/lib/utils/currency.ts b/src/lib/utils/currency.ts new file mode 100644 index 0000000..b8de224 --- /dev/null +++ b/src/lib/utils/currency.ts @@ -0,0 +1,174 @@ +/** + * Shared currency formatting utility. + * Reads org-level currency setting and formats amounts accordingly. + * + * For EUR, the symbol goes AFTER the number (e.g. "1 234,56 โ‚ฌ"). + * For USD/GBP, the symbol goes BEFORE (e.g. "$1,234.56"). + */ + +export interface CurrencyConfig { + currency: string; // ISO 4217 code: EUR, USD, GBP, etc. +} + +/** Locale map: which locale to use for each currency so symbol placement is correct */ +const CURRENCY_LOCALE_MAP: Record = { + EUR: 'de-DE', // puts โ‚ฌ after number: 1.234,56 โ‚ฌ + USD: 'en-US', // puts $ before number: $1,234.56 + GBP: 'en-GB', // puts ยฃ before number: ยฃ1,234.56 + SEK: 'sv-SE', // puts kr after number: 1 234,56 kr + NOK: 'nb-NO', // puts kr after number: 1 234,56 kr + DKK: 'da-DK', // puts kr after number: 1.234,56 kr + CHF: 'de-CH', // puts CHF before number: CHF 1'234.56 + PLN: 'pl-PL', // puts zล‚ after number: 1 234,56 zล‚ + CZK: 'cs-CZ', // puts Kฤ after number: 1 234,56 Kฤ + JPY: 'ja-JP', // puts ยฅ before number: ยฅ1,234 + CAD: 'en-CA', // puts $ before number: $1,234.56 + AUD: 'en-AU', // puts $ before number: $1,234.56 +}; + +/** All supported currencies with labels */ +export const SUPPORTED_CURRENCIES = [ + { code: 'EUR', label: 'Euro (โ‚ฌ)', symbol: 'โ‚ฌ' }, + { code: 'USD', label: 'US Dollar ($)', symbol: '$' }, + { code: 'GBP', label: 'British Pound (ยฃ)', symbol: 'ยฃ' }, + { code: 'SEK', label: 'Swedish Krona (kr)', symbol: 'kr' }, + { code: 'NOK', label: 'Norwegian Krone (kr)', symbol: 'kr' }, + { code: 'DKK', label: 'Danish Krone (kr)', symbol: 'kr' }, + { code: 'CHF', label: 'Swiss Franc (CHF)', symbol: 'CHF' }, + { code: 'PLN', label: 'Polish Zล‚oty (zล‚)', symbol: 'zล‚' }, + { code: 'CZK', label: 'Czech Koruna (Kฤ)', symbol: 'Kฤ' }, + { code: 'JPY', label: 'Japanese Yen (ยฅ)', symbol: 'ยฅ' }, + { code: 'CAD', label: 'Canadian Dollar ($)', symbol: '$' }, + { code: 'AUD', label: 'Australian Dollar ($)', symbol: '$' }, +] as const; + +/** + * Format a number as currency using the org's currency setting. + * Uses the correct locale for symbol placement. + */ +export function formatCurrency(amount: number, currency: string = 'EUR'): string { + const locale = CURRENCY_LOCALE_MAP[currency] ?? 'en-US'; + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + }).format(amount); +} + +/** Common timezones grouped by region */ +export const TIMEZONE_OPTIONS = [ + { + group: 'Europe', zones: [ + 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Helsinki', + 'Europe/Tallinn', 'Europe/Riga', 'Europe/Vilnius', 'Europe/Stockholm', + 'Europe/Oslo', 'Europe/Copenhagen', 'Europe/Warsaw', 'Europe/Prague', + 'Europe/Zurich', 'Europe/Amsterdam', 'Europe/Brussels', 'Europe/Madrid', + 'Europe/Rome', 'Europe/Athens', 'Europe/Bucharest', 'Europe/Moscow', + ] + }, + { + group: 'Americas', zones: [ + 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', + 'America/Toronto', 'America/Vancouver', 'America/Sao_Paulo', 'America/Mexico_City', + ] + }, + { + group: 'Asia / Pacific', zones: [ + 'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Singapore', 'Asia/Dubai', + 'Asia/Kolkata', 'Asia/Seoul', 'Australia/Sydney', 'Australia/Melbourne', + 'Pacific/Auckland', + ] + }, +]; + +/** + * Format a Date or ISO string using the org's date_format setting. + * Supports DD/MM/YYYY, MM/DD/YYYY, YYYY-MM-DD. + * If `short` is true, returns abbreviated format (e.g. "31 Dec" or "Dec 31"). + */ +export function formatDate( + dateInput: string | Date | null | undefined, + dateFormat: string = 'DD/MM/YYYY', + short: boolean = false, +): string { + if (!dateInput) return ''; + const d = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; + if (isNaN(d.getTime())) return ''; + + const day = d.getDate(); + const month = d.getMonth(); // 0-indexed + const year = d.getFullYear(); + const pad = (n: number) => String(n).padStart(2, '0'); + + const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + if (short) { + // Short format for card previews etc. + if (dateFormat === 'MM/DD/YYYY') { + return `${MONTHS_SHORT[month]} ${day}`; + } + return `${day} ${MONTHS_SHORT[month]}`; + } + + switch (dateFormat) { + case 'MM/DD/YYYY': + return `${pad(month + 1)}/${pad(day)}/${year}`; + case 'YYYY-MM-DD': + return `${year}-${pad(month + 1)}-${pad(day)}`; + case 'DD/MM/YYYY': + default: + return `${pad(day)}/${pad(month + 1)}/${year}`; + } +} + +/** Date format options */ +export const DATE_FORMAT_OPTIONS = [ + { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY (31/12/2026)' }, + { value: 'MM/DD/YYYY', label: 'MM/DD/YYYY (12/31/2026)' }, + { value: 'YYYY-MM-DD', label: 'YYYY-MM-DD (2026-12-31)' }, +]; + +/** Week start day options */ +export const WEEK_START_OPTIONS = [ + { value: 'monday', label: 'Monday' }, + { value: 'sunday', label: 'Sunday' }, +]; + +/** Calendar view options */ +export const CALENDAR_VIEW_OPTIONS = [ + { value: 'month', label: 'Month' }, + { value: 'week', label: 'Week' }, + { value: 'day', label: 'Day' }, +]; + +/** Event status options */ +export const EVENT_STATUS_OPTIONS = [ + { value: 'planning', label: 'Planning' }, + { value: 'active', label: 'Active' }, +]; + +/** Dashboard layout options */ +export const DASHBOARD_LAYOUT_OPTIONS = [ + { value: 'single', label: 'Single column' }, + { value: 'split', label: 'Split (2 columns)' }, + { value: 'grid', label: 'Grid (2ร—2)' }, + { value: 'focus_sidebar', label: 'Focus + sidebar' }, +]; + +/** Department module options */ +export const DEPT_MODULE_OPTIONS = [ + { value: 'kanban', label: 'Kanban', icon: 'view_kanban' }, + { value: 'files', label: 'Files', icon: 'folder' }, + { value: 'checklist', label: 'Checklist', icon: 'checklist' }, + { value: 'notes', label: 'Notes', icon: 'notes' }, + { value: 'schedule', label: 'Schedule', icon: 'schedule' }, + { value: 'contacts', label: 'Contacts', icon: 'contacts' }, + { value: 'budget', label: 'Budget', icon: 'account_balance' }, + { value: 'sponsors', label: 'Sponsors', icon: 'handshake' }, +]; + +/** Event color presets */ +export const EVENT_COLOR_PRESETS = [ + '#7986cb', '#33b679', '#8e24aa', '#e67c73', + '#f6bf26', '#f4511e', '#039be5', '#616161', + '#3f51b5', '#0b8043', '#d50000', '#f09300', +]; diff --git a/src/lib/utils/logger.test.ts b/src/lib/utils/logger.test.ts index 7032e94..c39303b 100644 --- a/src/lib/utils/logger.test.ts +++ b/src/lib/utils/logger.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { createLogger, setLogLevel, getRecentLogs, clearRecentLogs, dumpLogs } from './logger'; +import { createLogger, setLogLevel, getRecentLogs, clearRecentLogs, dumpLogs, getErrorMessage } from './logger'; describe('logger', () => { beforeEach(() => { @@ -111,4 +111,52 @@ describe('logger', () => { expect(recent[0].message).toBe('msg 10'); expect(recent[99].message).toBe('msg 109'); }); + + it('dumpLogs includes error formatting', () => { + const log = createLogger('ctx'); + log.error('fail', { error: new Error('boom'), data: { id: 1 } }); + + const dump = dumpLogs(); + expect(dump).toContain('[ERROR]'); + expect(dump).toContain('error: Error: boom'); + expect(dump).toContain('data:'); + }); + + it('debug level logs when minLevel is debug', () => { + setLogLevel('debug'); + const log = createLogger('test'); + log.debug('debug msg'); + expect(getRecentLogs()).toHaveLength(1); + expect(getRecentLogs()[0].level).toBe('debug'); + }); +}); + +describe('getErrorMessage', () => { + it('extracts message from Error instance', () => { + expect(getErrorMessage(new Error('oops'))).toBe('oops'); + }); + + it('returns string errors as-is', () => { + expect(getErrorMessage('string error')).toBe('string error'); + }); + + it('extracts message from object with message property', () => { + expect(getErrorMessage({ message: 'obj error' })).toBe('obj error'); + }); + + it('returns fallback for unknown types', () => { + expect(getErrorMessage(42)).toBe('An unexpected error occurred'); + }); + + it('returns custom fallback', () => { + expect(getErrorMessage(null, 'custom fallback')).toBe('custom fallback'); + }); + + it('returns fallback for object without message', () => { + expect(getErrorMessage({ code: 500 })).toBe('An unexpected error occurred'); + }); + + it('returns fallback for undefined', () => { + expect(getErrorMessage(undefined)).toBe('An unexpected error occurred'); + }); }); diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts index d10d807..322d1fc 100644 --- a/src/lib/utils/logger.ts +++ b/src/lib/utils/logger.ts @@ -40,7 +40,7 @@ const RESET = '\x1b[0m'; const BOLD = '\x1b[1m'; const DIM = '\x1b[2m'; -// Minimum level to output โ€” debug in dev, info in prod +// Minimum level to output - debug in dev, info in prod let minLevel: LogLevel = typeof window !== 'undefined' && window.location?.hostname === 'localhost' ? 'debug' : 'info'; function shouldLog(level: LogLevel): boolean { @@ -149,7 +149,7 @@ function log(level: LogLevel, context: string, message: string, extra?: { data?: return entry; } -// Ring buffer of recent logs โ€” useful for dumping context on crash +// Ring buffer of recent logs - useful for dumping context on crash const MAX_RECENT_LOGS = 100; const recentLogs: LogEntry[] = []; diff --git a/src/lib/utils/permissions.test.ts b/src/lib/utils/permissions.test.ts index b17d95b..6b7a8d2 100644 --- a/src/lib/utils/permissions.test.ts +++ b/src/lib/utils/permissions.test.ts @@ -8,13 +8,13 @@ import { } from './permissions'; describe('hasPermission', () => { - it('owner has wildcard โ€” grants any permission', () => { + it('owner has wildcard - grants any permission', () => { expect(hasPermission('owner', null, PERMISSIONS.DOCUMENTS_VIEW)).toBe(true); expect(hasPermission('owner', null, PERMISSIONS.SETTINGS_EDIT)).toBe(true); expect(hasPermission('owner', null, PERMISSIONS.MEMBERS_REMOVE)).toBe(true); }); - it('admin has wildcard โ€” grants any permission', () => { + it('admin has wildcard - grants any permission', () => { expect(hasPermission('admin', null, PERMISSIONS.ROLES_DELETE)).toBe(true); expect(hasPermission('admin', null, PERMISSIONS.KANBAN_CREATE)).toBe(true); }); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c62a183..ab26893 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,6 @@ - Organizations | Root + Organizations | root
-
+
-
- hub -
- Root +
- +
@@ -80,43 +83,79 @@
-

Your Organizations

-

Select an organization to get started

+

+ Your Organizations +

+

+ Select an organization to get started +

{#if organizations.length === 0} -
-
- groups +
+
+ groups
-

No organizations yet

-

Create your first organization to start collaborating

+

+ No organizations yet +

+

+ Create your first organization to start collaborating +

+ >Create Organization
{:else}
{#each organizations as org} -
+
-
+
{org.name.charAt(0).toUpperCase()}
- {org.role} + {org.role}
-

{org.name}

-

/{org.slug}

+

+ {org.name} +

+

+ /{org.slug} +

{/each} @@ -132,7 +171,9 @@ >
- + {#if newOrgName}

- URL: /{generateSlug(newOrgName)} + URL: /{generateSlug(newOrgName)}

{/if} -
- +
+ - {/each} - -
-
- - -
-
-

- {m.account_use_org_theme()} -

-

- {m.account_use_org_theme_desc()} -

-
- -
-
-
+

{m.account_security()}

diff --git a/src/routes/[orgSlug]/calendar/+page.svelte b/src/routes/[orgSlug]/calendar/+page.svelte index ef08d49..62bad97 100644 --- a/src/routes/[orgSlug]/calendar/+page.svelte +++ b/src/routes/[orgSlug]/calendar/+page.svelte @@ -21,7 +21,12 @@ interface Props { data: { - org: { id: string; name: string; slug: string }; + org: { + id: string; + name: string; + slug: string; + default_calendar_view: string; + }; events: CalendarEvent[]; user: { id: string } | null; userRole?: string; @@ -148,7 +153,7 @@ /** * Push event to Google Calendar in the background. - * Does not block the UI โ€” updates google_event_id on success. + * Does not block the UI - updates google_event_id on success. */ async function syncToGoogle( action: "create" | "update" | "delete", @@ -454,12 +459,14 @@ - Calendar - {data.org.name} | Root + Calendar - {data.org.name} | root
-
+
diff --git a/src/routes/[orgSlug]/chat/+page.svelte b/src/routes/[orgSlug]/chat/+page.svelte index e5ebc46..ba925b2 100644 --- a/src/routes/[orgSlug]/chat/+page.svelte +++ b/src/routes/[orgSlug]/chat/+page.svelte @@ -47,7 +47,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { createLogger, getErrorMessage } from "$lib/utils/logger"; - const log = createLogger('chat:page'); + const log = createLogger("chat:page"); const supabase = getContext("supabase"); let data = $derived(page.data); @@ -79,9 +79,7 @@ ); // All non-space rooms (exclude Space entries themselves from the list) - const allRooms = $derived( - $roomSummaries.filter((r) => !r.isSpace), - ); + const allRooms = $derived($roomSummaries.filter((r) => !r.isSpace)); // Org rooms: rooms that belong to any Space const orgRooms = $derived( @@ -89,9 +87,7 @@ ); // DMs: direct messages (not tied to org) - const dmRooms = $derived( - allRooms.filter((r) => r.isDirect), - ); + const dmRooms = $derived(allRooms.filter((r) => r.isDirect)); // Other rooms: not in a space and not a DM const otherRooms = $derived( @@ -103,8 +99,12 @@ roomSearchQuery.trim() ? rooms.filter( (room) => - room.name.toLowerCase().includes(roomSearchQuery.toLowerCase()) || - room.topic?.toLowerCase().includes(roomSearchQuery.toLowerCase()), + room.name + .toLowerCase() + .includes(roomSearchQuery.toLowerCase()) || + room.topic + ?.toLowerCase() + .includes(roomSearchQuery.toLowerCase()), ) : rooms; @@ -123,12 +123,14 @@ await initCache(); await cleanupCache(7 * 24 * 60 * 60 * 1000); } catch (e) { - log.warn('Cache initialization failed', { error: e }); + log.warn("Cache initialization failed", { error: e }); } // Try to load credentials from Supabase try { - const res = await fetch(`/api/matrix-credentials?org_id=${data.org.id}`); + const res = await fetch( + `/api/matrix-credentials?org_id=${data.org.id}`, + ); const result = await res.json(); if (result.credentials) { @@ -139,12 +141,12 @@ deviceId: result.credentials.device_id, }); } else { - // No stored credentials โ€” show join screen + // No stored credentials - show join screen showJoinScreen = true; isInitializing = false; } } catch (e) { - log.error('Failed to load Matrix credentials', { error: e }); + log.error("Failed to load Matrix credentials", { error: e }); showJoinScreen = true; isInitializing = false; } @@ -167,7 +169,7 @@ // Check if org has a Matrix Space, auto-create if not await ensureOrgSpace(credentials); } catch (e: unknown) { - log.error('Failed to init Matrix client', { error: e }); + log.error("Failed to init Matrix client", { error: e }); toasts.error(m.chat_join_error()); showJoinScreen = true; } finally { @@ -177,11 +179,13 @@ async function ensureOrgSpace(credentials: LoginCredentials) { try { - const spaceRes = await fetch(`/api/matrix-space?org_id=${data.org.id}`); + const spaceRes = await fetch( + `/api/matrix-space?org_id=${data.org.id}`, + ); const spaceResult = await spaceRes.json(); if (!spaceResult.spaceId) { - // No Space yet โ€” create one using the user's credentials + // No Space yet - create one using the user's credentials const createRes = await fetch("/api/matrix-space", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -199,7 +203,7 @@ } } } catch (e) { - log.warn('Failed to ensure org space', { error: e }); + log.warn("Failed to ensure org space", { error: e }); } } @@ -263,7 +267,12 @@ async function handleReact(messageId: string, emoji: string) { if (!$selectedRoomId || !$auth.userId) return; try { - await reactionService.add($selectedRoomId, messageId, emoji, $auth.userId); + await reactionService.add( + $selectedRoomId, + messageId, + emoji, + $auth.userId, + ); } catch (e) { const error = e as { message?: string }; toasts.error(error.message || "Failed to add reaction"); @@ -301,7 +310,7 @@ editingMsg = null; toasts.success("Message edited"); } catch (e: unknown) { - toasts.error(getErrorMessage(e, 'Failed to edit message')); + toasts.error(getErrorMessage(e, "Failed to edit message")); } } @@ -316,7 +325,7 @@ await deleteMessage($selectedRoomId, messageId); toasts.success("Message deleted"); } catch (e: unknown) { - toasts.error(getErrorMessage(e, 'Failed to delete message')); + toasts.error(getErrorMessage(e, "Failed to delete message")); } } @@ -359,7 +368,7 @@ await sendFileMessage($selectedRoomId, file, contentUri); toasts.success("File sent!"); } catch (e: unknown) { - toasts.error(getErrorMessage(e, 'Failed to upload file')); + toasts.error(getErrorMessage(e, "Failed to upload file")); } finally { isUploadingDrop = false; } @@ -373,22 +382,31 @@ loadRoomMessages($selectedRoomId); if (!result.hasMore) toasts.info("No more messages to load"); } catch (e: unknown) { - log.error('Failed to load more messages', { error: e }); + log.error("Failed to load more messages", { error: e }); } finally { isLoadingMore = false; } } + + {m.page_chat()} | {page.params.orgSlug} + + {#if showJoinScreen}
-
+
chat -

{m.chat_join_title()}

+ >chat +

+ {m.chat_join_title()} +

{m.chat_join_description()}

@@ -414,7 +432,7 @@
- + {:else if isInitializing || ($syncState !== "PREPARED" && $syncState !== "SYNCING")}
@@ -437,21 +455,31 @@
- + {:else if matrixClient} {#snippet children()}
-