From 8c41b333135a13e67f414d651cad7854ed69e737 Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:50:15 -0500 Subject: [PATCH] Add 17 features: My Work view, card copy/numbering, calendar, bulk ops, custom fields, automations, webhooks, invites, import/export, and more Features implemented across 6 batches: - Batch A: Fix ColorPicker gradient sync, tenant custom logo, unarchive cards, board backgrounds - Batch B: My Work view, card copy/duplicate, card numbering (BOARD-123) - Batch C: Due date reminders (cron), card watching with notifications - Batch D: Bulk card operations, column sorting, calendar view - Batch E: Invite by link, board import/export (Pounce + Trello JSON) - Batch F: Webhooks with HMAC signing, custom fields, automation rules engine Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 22 ++ package.json | 1 + prisma/schema.prisma | 169 ++++++++++-- scripts/setup-rls.sql | 124 +++++++++ server.js | 53 ++++ src/app.d.ts | 2 + src/lib/components/AutomationBuilder.svelte | 257 ++++++++++++++++++ .../components/BoardBackgroundPicker.svelte | 145 ++++++++++ src/lib/components/BulkToolbar.svelte | 100 +++++++ src/lib/components/CalendarGrid.svelte | 138 ++++++++++ src/lib/components/CardModal.svelte | 90 +++++- src/lib/components/ColorPicker.svelte | 38 +-- src/lib/components/ColumnSortMenu.svelte | 53 ++++ src/lib/components/CustomFieldEditor.svelte | 118 ++++++++ src/lib/components/CustomFieldsSection.svelte | 70 +++++ src/lib/components/KeyboardShortcuts.svelte | 4 + src/lib/server/automations.ts | 148 ++++++++++ src/lib/server/notifications.ts | 27 ++ src/lib/server/tenant.ts | 4 + src/lib/server/validation.ts | 3 +- src/lib/server/webhooks.ts | 29 ++ src/routes/+layout.svelte | 33 ++- src/routes/admin/+page.svelte | 235 ++++++++++++++++ src/routes/admin/branding/+page.server.ts | 18 +- src/routes/admin/branding/+page.svelte | 27 +- src/routes/api/admin/branding/+server.ts | 11 + .../api/admin/branding/images/+server.ts | 51 ++-- src/routes/api/admin/invites/+server.ts | 51 ++++ .../api/admin/invites/[inviteId]/+server.ts | 24 ++ src/routes/api/admin/webhooks/+server.ts | 50 ++++ .../api/admin/webhooks/[webhookId]/+server.ts | 52 ++++ src/routes/api/boards/+server.ts | 2 + .../boards/[boardId]/automations/+server.ts | 58 ++++ .../[boardId]/automations/[ruleId]/+server.ts | 50 ++++ .../boards/[boardId]/cards/bulk/+server.ts | 140 ++++++++++ .../columns/[columnId]/cards/+server.ts | 18 +- .../[columnId]/cards/[cardId]/+server.ts | 25 +- .../[columnId]/cards/[cardId]/copy/+server.ts | 130 +++++++++ .../cards/[cardId]/custom-fields/+server.ts | 49 ++++ .../cards/[cardId]/watch/+server.ts | 47 ++++ .../boards/[boardId]/custom-fields/+server.ts | 64 +++++ .../custom-fields/[fieldId]/+server.ts | 50 ++++ .../api/boards/[boardId]/export/+server.ts | 100 +++++++ src/routes/api/boards/import/+server.ts | 201 ++++++++++++++ src/routes/api/invite/[code]/+server.ts | 42 +++ src/routes/api/search/+server.ts | 32 +++ src/routes/boards/+page.svelte | 42 +++ src/routes/boards/[boardId]/+page.svelte | 163 ++++++++++- .../boards/[boardId]/calendar/+page.server.ts | 35 +++ .../boards/[boardId]/calendar/+page.svelte | 49 ++++ src/routes/invite/[code]/+page.server.ts | 32 +++ src/routes/invite/[code]/+page.svelte | 93 +++++++ src/routes/my-work/+page.server.ts | 46 ++++ src/routes/my-work/+page.svelte | 123 +++++++++ 54 files changed, 3656 insertions(+), 82 deletions(-) create mode 100644 src/lib/components/AutomationBuilder.svelte create mode 100644 src/lib/components/BoardBackgroundPicker.svelte create mode 100644 src/lib/components/BulkToolbar.svelte create mode 100644 src/lib/components/CalendarGrid.svelte create mode 100644 src/lib/components/ColumnSortMenu.svelte create mode 100644 src/lib/components/CustomFieldEditor.svelte create mode 100644 src/lib/components/CustomFieldsSection.svelte create mode 100644 src/lib/server/automations.ts create mode 100644 src/lib/server/webhooks.ts create mode 100644 src/routes/api/admin/invites/+server.ts create mode 100644 src/routes/api/admin/invites/[inviteId]/+server.ts create mode 100644 src/routes/api/admin/webhooks/+server.ts create mode 100644 src/routes/api/admin/webhooks/[webhookId]/+server.ts create mode 100644 src/routes/api/boards/[boardId]/automations/+server.ts create mode 100644 src/routes/api/boards/[boardId]/automations/[ruleId]/+server.ts create mode 100644 src/routes/api/boards/[boardId]/cards/bulk/+server.ts create mode 100644 src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/copy/+server.ts create mode 100644 src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/custom-fields/+server.ts create mode 100644 src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/watch/+server.ts create mode 100644 src/routes/api/boards/[boardId]/custom-fields/+server.ts create mode 100644 src/routes/api/boards/[boardId]/custom-fields/[fieldId]/+server.ts create mode 100644 src/routes/api/boards/[boardId]/export/+server.ts create mode 100644 src/routes/api/boards/import/+server.ts create mode 100644 src/routes/api/invite/[code]/+server.ts create mode 100644 src/routes/boards/[boardId]/calendar/+page.server.ts create mode 100644 src/routes/boards/[boardId]/calendar/+page.svelte create mode 100644 src/routes/invite/[code]/+page.server.ts create mode 100644 src/routes/invite/[code]/+page.svelte create mode 100644 src/routes/my-work/+page.server.ts create mode 100644 src/routes/my-work/+page.svelte diff --git a/package-lock.json b/package-lock.json index 35154b3..5e59585 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "dompurify": "^3.3.1", "express": "^5.1.0", "marked": "^15.0.0", + "node-cron": "^3.0.3", "pino": "^10.3.1", "socket.io": "^4.8.0", "socket.io-client": "^4.8.0", @@ -3112,6 +3113,18 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -4176,6 +4189,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index cf057d8..4d2b032 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "date-fns": "^4.1.0", "dompurify": "^3.3.1", "express": "^5.1.0", + "node-cron": "^3.0.3", "marked": "^15.0.0", "pino": "^10.3.1", "socket.io": "^4.8.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8c67e6a..0101a75 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,8 @@ model Tenant { id String @id @default(uuid()) @db.Uuid name String slug String @unique + logoUrl String? @map("logo_url") + logoText String? @map("logo_text") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -23,6 +25,8 @@ model Tenant { categories Category[] boardTemplates BoardTemplate[] theme TenantTheme? + invites TenantInvite[] + webhooks Webhook[] @@map("tenants") } @@ -70,6 +74,7 @@ model User { comments Comment[] activities Activity[] notifications Notification[] + cardWatchers CardWatcher[] @@unique([tenantId, email]) @@map("users") @@ -108,14 +113,18 @@ model Board { background String? categoryId String? @map("category_id") @db.Uuid archived Boolean @default(false) + cardPrefix String? @map("card_prefix") + nextCardNum Int @default(1) @map("next_card_num") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) - columns Column[] - members BoardMember[] - activities Activity[] + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + columns Column[] + members BoardMember[] + activities Activity[] + customFieldDefs CustomFieldDef[] + automationRules AutomationRule[] @@map("boards") } @@ -150,23 +159,27 @@ model Column { // ─── Cards ──────────────────────────────────────────────────── model Card { - id String @id @default(uuid()) @db.Uuid - columnId String @map("column_id") @db.Uuid - title String - description String? - position String // Fractional index (lexicographic) - dueDate DateTime? @map("due_date") - archived Boolean @default(false) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) @db.Uuid + columnId String @map("column_id") @db.Uuid + title String + description String? + position String // Fractional index (lexicographic) + dueDate DateTime? @map("due_date") + archived Boolean @default(false) + dueReminderSent Boolean @default(false) @map("due_reminder_sent") + cardNumber Int? @map("card_number") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - column Column @relation(fields: [columnId], references: [id], onDelete: Cascade) - assignees CardAssignee[] - labels CardLabel[] - checklists Checklist[] - comments Comment[] - attachments Attachment[] - activities Activity[] + column Column @relation(fields: [columnId], references: [id], onDelete: Cascade) + assignees CardAssignee[] + labels CardLabel[] + checklists Checklist[] + comments Comment[] + attachments Attachment[] + activities Activity[] + watchers CardWatcher[] + customFieldValues CustomFieldValue[] @@map("cards") } @@ -331,6 +344,120 @@ model BoardTemplate { @@map("board_templates") } +// ─── Card Watchers ─────────────────────────────────────────── + +model CardWatcher { + id String @id @default(uuid()) @db.Uuid + cardId String @map("card_id") @db.Uuid + userId String @map("user_id") @db.Uuid + + card Card @relation(fields: [cardId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([cardId, userId]) + @@map("card_watchers") +} + +// ─── Tenant Invites ────────────────────────────────────────── + +model TenantInvite { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + code String @unique + role TenantRole @default(MEMBER) + expiresAt DateTime @map("expires_at") + createdBy String @map("created_by") @db.Uuid + usedCount Int @default(0) @map("used_count") + maxUses Int? @map("max_uses") + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + + @@map("tenant_invites") +} + +// ─── Webhooks ──────────────────────────────────────────────── + +model Webhook { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + url String + events String[] + secret String + active Boolean @default(true) + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + + @@map("webhooks") +} + +// ─── Custom Fields ─────────────────────────────────────────── + +enum CustomFieldType { + TEXT + NUMBER + DATE + SELECT + MULTI_SELECT +} + +model CustomFieldDef { + id String @id @default(uuid()) @db.Uuid + boardId String @map("board_id") @db.Uuid + name String + type CustomFieldType + options Json? // For SELECT/MULTI_SELECT: string[] + position String + + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + values CustomFieldValue[] + + @@map("custom_field_defs") +} + +model CustomFieldValue { + id String @id @default(uuid()) @db.Uuid + cardId String @map("card_id") @db.Uuid + fieldId String @map("field_id") @db.Uuid + value String + + card Card @relation(fields: [cardId], references: [id], onDelete: Cascade) + field CustomFieldDef @relation(fields: [fieldId], references: [id], onDelete: Cascade) + + @@unique([cardId, fieldId]) + @@map("custom_field_values") +} + +// ─── Automation Rules ──────────────────────────────────────── + +enum AutomationTrigger { + CARD_MOVED + DUE_PASSED + LABEL_ADDED + CARD_CREATED +} + +enum AutomationAction { + ADD_LABEL + REMOVE_LABEL + ASSIGN + SET_DUE + MOVE_TO_COLUMN +} + +model AutomationRule { + id String @id @default(uuid()) @db.Uuid + boardId String @map("board_id") @db.Uuid + trigger AutomationTrigger + triggerConfig Json? @map("trigger_config") + action AutomationAction + actionConfig Json? @map("action_config") + active Boolean @default(true) + + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + + @@map("automation_rules") +} + // ─── Tenant Branding & Theming ─────────────────────────────── model TenantTheme { diff --git a/scripts/setup-rls.sql b/scripts/setup-rls.sql index c5d5678..73be03f 100644 --- a/scripts/setup-rls.sql +++ b/scripts/setup-rls.sql @@ -60,6 +60,24 @@ ALTER TABLE notifications FORCE ROW LEVEL SECURITY; ALTER TABLE board_templates ENABLE ROW LEVEL SECURITY; ALTER TABLE board_templates FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant_invites ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant_invites FORCE ROW LEVEL SECURITY; + +ALTER TABLE webhooks ENABLE ROW LEVEL SECURITY; +ALTER TABLE webhooks FORCE ROW LEVEL SECURITY; + +ALTER TABLE card_watchers ENABLE ROW LEVEL SECURITY; +ALTER TABLE card_watchers FORCE ROW LEVEL SECURITY; + +ALTER TABLE custom_field_defs ENABLE ROW LEVEL SECURITY; +ALTER TABLE custom_field_defs FORCE ROW LEVEL SECURITY; + +ALTER TABLE custom_field_values ENABLE ROW LEVEL SECURITY; +ALTER TABLE custom_field_values FORCE ROW LEVEL SECURITY; + +ALTER TABLE automation_rules ENABLE ROW LEVEL SECURITY; +ALTER TABLE automation_rules FORCE ROW LEVEL SECURITY; + -- ─── Tenant-scoped tables (direct tenant_id) ────────── -- Users @@ -336,6 +354,112 @@ CREATE POLICY attachments_tenant_isolation ON attachments ) ); +-- ─── New tenant-scoped tables ──────────────────────── + +-- Tenant Invites +DROP POLICY IF EXISTS tenant_invites_tenant_isolation ON tenant_invites; +CREATE POLICY tenant_invites_tenant_isolation ON tenant_invites + USING ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ); + +-- Webhooks +DROP POLICY IF EXISTS webhooks_tenant_isolation ON webhooks; +CREATE POLICY webhooks_tenant_isolation ON webhooks + USING ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ); + +-- Card Watchers (via card -> column -> board) +DROP POLICY IF EXISTS card_watchers_tenant_isolation ON card_watchers; +CREATE POLICY card_watchers_tenant_isolation ON card_watchers + USING ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR card_id IN ( + SELECT ca.id FROM cards ca + JOIN columns c ON ca.column_id = c.id + JOIN boards b ON c.board_id = b.id + WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR card_id IN ( + SELECT ca.id FROM cards ca + JOIN columns c ON ca.column_id = c.id + JOIN boards b ON c.board_id = b.id + WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ); + +-- Custom Field Defs (via board) +DROP POLICY IF EXISTS custom_field_defs_tenant_isolation ON custom_field_defs; +CREATE POLICY custom_field_defs_tenant_isolation ON custom_field_defs + USING ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR board_id IN ( + SELECT id FROM boards + WHERE tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR board_id IN ( + SELECT id FROM boards + WHERE tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ); + +-- Custom Field Values (via card -> column -> board) +DROP POLICY IF EXISTS custom_field_values_tenant_isolation ON custom_field_values; +CREATE POLICY custom_field_values_tenant_isolation ON custom_field_values + USING ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR card_id IN ( + SELECT ca.id FROM cards ca + JOIN columns c ON ca.column_id = c.id + JOIN boards b ON c.board_id = b.id + WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR card_id IN ( + SELECT ca.id FROM cards ca + JOIN columns c ON ca.column_id = c.id + JOIN boards b ON c.board_id = b.id + WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ); + +-- Automation Rules (via board) +DROP POLICY IF EXISTS automation_rules_tenant_isolation ON automation_rules; +CREATE POLICY automation_rules_tenant_isolation ON automation_rules + USING ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR board_id IN ( + SELECT id FROM boards + WHERE tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR board_id IN ( + SELECT id FROM boards + WHERE tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ); + -- ─── User-scoped tables ─────────────────────────────── -- Notifications diff --git a/server.js b/server.js index 3a038ca..4717bb4 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,7 @@ import { createServer } from 'http'; import { Server } from 'socket.io'; import { PrismaClient } from '@prisma/client'; import pino from 'pino'; +import cron from 'node-cron'; import { handler } from './build/handler.js'; const logger = pino({ name: 'pounce' }); @@ -148,6 +149,58 @@ io.on('connection', (socket) => { // Store io globally so SvelteKit API routes can broadcast via realtime.ts globalThis.__socketIO = io; +// ─── Due Date Reminder Cron (daily at 8:00 UTC) ──────────── +cron.schedule('0 8 * * *', async () => { + try { + const now = new Date(); + const in24h = new Date(now.getTime() + 24 * 60 * 60 * 1000); + + const cards = await prisma.card.findMany({ + where: { + archived: false, + dueReminderSent: false, + dueDate: { gte: now, lte: in24h } + }, + include: { + assignees: { select: { userId: true } }, + column: { select: { board: { select: { id: true, title: true } } } } + } + }); + + for (const card of cards) { + const boardId = card.column.board.id; + const boardTitle = card.column.board.title; + + for (const assignee of card.assignees) { + await prisma.notification.create({ + data: { + userId: assignee.userId, + type: 'due_soon', + title: `"${card.title}" is due soon`, + body: `Card in ${boardTitle} is due within 24 hours`, + link: `/boards/${boardId}?card=${card.id}` + } + }); + io.to('user:' + assignee.userId).emit('notification:new', { + type: 'due_soon', + title: `"${card.title}" is due soon` + }); + } + + await prisma.card.update({ + where: { id: card.id }, + data: { dueReminderSent: true } + }); + } + + if (cards.length > 0) { + logger.info({ count: cards.length }, 'Due date reminders sent'); + } + } catch (err) { + logger.error({ err }, 'Due date reminder cron failed'); + } +}); + // SvelteKit handler app.use(handler); diff --git a/src/app.d.ts b/src/app.d.ts index 7f13b9e..afd1632 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -53,6 +53,8 @@ declare global { id: string; name: string; slug: string; + logoUrl?: string | null; + logoText?: string | null; theme?: TenantThemeData | null; } | null; user: { diff --git a/src/lib/components/AutomationBuilder.svelte b/src/lib/components/AutomationBuilder.svelte new file mode 100644 index 0000000..8d7522b --- /dev/null +++ b/src/lib/components/AutomationBuilder.svelte @@ -0,0 +1,257 @@ + + +
+ {#if !loaded} + + {:else} +
+ Automations + +
+ + {#if showCreate} +
+
+
+ + +
+
+ + +
+
+ + + {#if newTrigger === 'CARD_MOVED'} + + {:else if newTrigger === 'LABEL_ADDED'} + + {/if} + + + {#if newAction === 'ADD_LABEL' || newAction === 'REMOVE_LABEL'} + + {:else if newAction === 'ASSIGN'} + + {:else if newAction === 'MOVE_TO_COLUMN'} + + {:else if newAction === 'SET_DUE'} +
+ + days from now +
+ {/if} + + +
+ {/if} + + {#if rules.length === 0} +

No automation rules

+ {:else} +
+ {#each rules as rule (rule.id)} +
+
+ {getTriggerLabel(rule.trigger)} + {#if describeConfig(rule.triggerConfig, 'trigger')} + {describeConfig(rule.triggerConfig, 'trigger')} + {/if} + + {getActionLabel(rule.action)} + {#if describeConfig(rule.actionConfig, 'action')} + {describeConfig(rule.actionConfig, 'action')} + {/if} +
+ + +
+ {/each} +
+ {/if} + {/if} +
diff --git a/src/lib/components/BoardBackgroundPicker.svelte b/src/lib/components/BoardBackgroundPicker.svelte new file mode 100644 index 0000000..37e08fc --- /dev/null +++ b/src/lib/components/BoardBackgroundPicker.svelte @@ -0,0 +1,145 @@ + + + + +
+ + + {#if open} +
e.stopPropagation()} + > + {#if !customMode} +
+
Solid Colors
+
+ {#each PRESET_SOLIDS as color} + + {/each} +
+
+ +
+
Gradients
+
+ {#each PRESET_GRADIENTS as grad} + + {/each} +
+
+ +
+ + {#if currentBackground} + + {/if} +
+ {:else} + +
+ + +
+ {/if} +
+ {/if} +
diff --git a/src/lib/components/BulkToolbar.svelte b/src/lib/components/BulkToolbar.svelte new file mode 100644 index 0000000..b0e030d --- /dev/null +++ b/src/lib/components/BulkToolbar.svelte @@ -0,0 +1,100 @@ + + +
+ + {selectedCardIds.size} selected + + +
+ + + + + + + {#if confirmDelete} + + + {:else} + + {/if} + +
+ + +
diff --git a/src/lib/components/CalendarGrid.svelte b/src/lib/components/CalendarGrid.svelte new file mode 100644 index 0000000..f12dca6 --- /dev/null +++ b/src/lib/components/CalendarGrid.svelte @@ -0,0 +1,138 @@ + + +
+ +
+
+ +

+ {MONTHS[month]} {year} +

+ +
+ +
+ + +
+ +
+ {#each DAYS as day} +
{day}
+ {/each} +
+ + +
+ {#each calendarDays as day, i} + {@const dayCards = day ? getCardsForDay(day) : []} +
+ {#if day} +
+ {day} +
+ {#each dayCards.slice(0, 3) as card} + {@const urgency = getDueUrgency(card.dueDate)} + + {card.title} + + {/each} + {#if dayCards.length > 3} +
+{dayCards.length - 3} more
+ {/if} + {/if} +
+ {/each} +
+
+
diff --git a/src/lib/components/CardModal.svelte b/src/lib/components/CardModal.svelte index b4bbd8a..ed4a358 100644 --- a/src/lib/components/CardModal.svelte +++ b/src/lib/components/CardModal.svelte @@ -9,6 +9,7 @@ import CommentList from './CommentList.svelte'; import AttachmentList from './AttachmentList.svelte'; import ActivityFeed from './ActivityFeed.svelte'; + import CustomFieldsSection from './CustomFieldsSection.svelte'; import { api } from '$lib/utils/api.js'; type Props = { @@ -18,12 +19,14 @@ canEdit: boolean; boardMembers: any[]; currentUserId: string; + cardPrefix?: string | null; onclose: () => void; ondelete: (cardId: string) => void; onupdate: (card: any) => void; + oncopied?: (card: any) => void; }; - let { card, boardId, columnId, canEdit, boardMembers, currentUserId, onclose, ondelete, onupdate }: Props = $props(); + let { card, boardId, columnId, canEdit, boardMembers, currentUserId, cardPrefix, onclose, ondelete, onupdate, oncopied }: Props = $props(); let editingTitle = $state(false); let title = $state(card.title); @@ -110,7 +113,49 @@ body: JSON.stringify({ archived: true }) }); if (ok) { - ondelete(card.id); + card.archived = true; + onupdate(card); + onclose(); + } + } + + let isWatching = $derived( + fullCard?.watchers?.some((w: any) => w.userId === currentUserId) ?? false + ); + + async function toggleWatch() { + if (isWatching) { + const { ok } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}/watch`, { method: 'DELETE' }); + if (ok && fullCard) { + fullCard.watchers = fullCard.watchers.filter((w: any) => w.userId !== currentUserId); + } + } else { + const { ok } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}/watch`, { method: 'POST' }); + if (ok && fullCard) { + fullCard.watchers = [...(fullCard.watchers || []), { userId: currentUserId }]; + } + } + } + + async function copyCard() { + const { ok, data: result } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}/copy`, { + method: 'POST' + }); + if (ok) { + oncopied?.(result.card); + onclose(); + } + } + + async function unarchiveCard() { + const { ok } = await api(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, { + method: 'PATCH', + body: JSON.stringify({ archived: false }) + }); + if (ok) { + card.archived = false; + onupdate(card); + onclose(); } } @@ -126,6 +171,9 @@
+ {#if cardPrefix && card.cardNumber} + {cardPrefix}-{card.cardNumber} + {/if} {#if editingTitle && canEdit}
{ e.preventDefault(); saveTitle(); }}> + +

Actions

+ {#if fullCard} + + {/if} + {#if card.archived} + + {:else} + + {/if} + + {#if open} + +
e.stopPropagation()} + > + {#each options as opt} + + {/each} +
+ {/if} +
diff --git a/src/lib/components/CustomFieldEditor.svelte b/src/lib/components/CustomFieldEditor.svelte new file mode 100644 index 0000000..7986257 --- /dev/null +++ b/src/lib/components/CustomFieldEditor.svelte @@ -0,0 +1,118 @@ + + +
+ + {field.name} + + + {#if editing} +
+ {#if field.type === 'TEXT'} + { if (e.key === 'Enter') save(); if (e.key === 'Escape') cancel(); }} + /> + {:else if field.type === 'NUMBER'} + { if (e.key === 'Enter') save(); if (e.key === 'Escape') cancel(); }} + /> + {:else if field.type === 'DATE'} + + {:else if field.type === 'SELECT'} + + {:else if field.type === 'MULTI_SELECT'} +
+ {#each (field.options || []) as opt} + {@const selected = editValue.split(',').includes(opt)} + + {/each} +
+ {/if} + + +
+ {:else} + + {/if} +
diff --git a/src/lib/components/CustomFieldsSection.svelte b/src/lib/components/CustomFieldsSection.svelte new file mode 100644 index 0000000..c052b88 --- /dev/null +++ b/src/lib/components/CustomFieldsSection.svelte @@ -0,0 +1,70 @@ + + +{#if loaded && fields.length > 0} +
+

Custom Fields

+
+ {#each fields as field (field.id)} + saveValue(field.id, v)} + /> + {/each} +
+
+{/if} diff --git a/src/lib/components/KeyboardShortcuts.svelte b/src/lib/components/KeyboardShortcuts.svelte index 2338add..0a2f3e9 100644 --- a/src/lib/components/KeyboardShortcuts.svelte +++ b/src/lib/components/KeyboardShortcuts.svelte @@ -27,6 +27,9 @@ case 'b': goto('/boards'); break; + case 'm': + goto('/my-work'); + break; case '?': e.preventDefault(); showHelp = !showHelp; @@ -49,6 +52,7 @@ const shortcuts = [ { key: '/', desc: 'Focus search' }, { key: 'b', desc: 'Go to boards' }, + { key: 'm', desc: 'My Work' }, { key: '?', desc: 'Toggle this help' }, { key: 'Esc', desc: 'Close dialogs' }, { key: 'n', desc: 'New card (on board)' } diff --git a/src/lib/server/automations.ts b/src/lib/server/automations.ts new file mode 100644 index 0000000..7212b10 --- /dev/null +++ b/src/lib/server/automations.ts @@ -0,0 +1,148 @@ +import type { Prisma } from '@prisma/client'; + +type TxClient = Prisma.TransactionClient; + +interface AutomationContext { + cardId: string; + cardTitle?: string; + fromColumnId?: string; + toColumnId?: string; + labelTagId?: string; + userId?: string; +} + +const MAX_DEPTH = 3; + +export async function runAutomations( + tx: TxClient, + boardId: string, + trigger: string, + context: AutomationContext, + depth = 0 +) { + if (depth >= MAX_DEPTH) return; + + const rules = await tx.automationRule.findMany({ + where: { boardId, trigger: trigger as any, active: true } + }); + + for (const rule of rules) { + const triggerConfig = (rule.triggerConfig as Record) || {}; + const actionConfig = (rule.actionConfig as Record) || {}; + + // Check trigger conditions + if (!matchesTrigger(trigger, triggerConfig, context)) continue; + + // Execute action + await executeAction(tx, boardId, rule.action, actionConfig, context, depth); + } +} + +function matchesTrigger( + trigger: string, + config: Record, + context: AutomationContext +): boolean { + switch (trigger) { + case 'CARD_MOVED': + if (config.toColumnId && config.toColumnId !== context.toColumnId) return false; + if (config.fromColumnId && config.fromColumnId !== context.fromColumnId) return false; + return true; + + case 'LABEL_ADDED': + if (config.tagId && config.tagId !== context.labelTagId) return false; + return true; + + case 'CARD_CREATED': + return true; + + case 'DUE_PASSED': + return true; + + default: + return false; + } +} + +async function executeAction( + tx: TxClient, + boardId: string, + action: string, + config: Record, + context: AutomationContext, + depth: number +) { + const { cardId } = context; + + switch (action) { + case 'ADD_LABEL': { + if (!config.tagId) return; + const existing = await tx.cardLabel.findFirst({ + where: { cardId, tagId: config.tagId } + }); + if (!existing) { + await tx.cardLabel.create({ + data: { cardId, tagId: config.tagId } + }); + // Potentially trigger LABEL_ADDED automation + await runAutomations(tx, boardId, 'LABEL_ADDED', { + ...context, + labelTagId: config.tagId + }, depth + 1); + } + break; + } + + case 'REMOVE_LABEL': { + if (!config.tagId) return; + await tx.cardLabel.deleteMany({ + where: { cardId, tagId: config.tagId } + }); + break; + } + + case 'ASSIGN': { + if (!config.userId) return; + const existing = await tx.cardAssignee.findFirst({ + where: { cardId, userId: config.userId } + }); + if (!existing) { + await tx.cardAssignee.create({ + data: { cardId, userId: config.userId } + }); + } + break; + } + + case 'SET_DUE': { + if (!config.daysFromNow) return; + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() + parseInt(config.daysFromNow)); + await tx.card.update({ + where: { id: cardId }, + data: { dueDate } + }); + break; + } + + case 'MOVE_TO_COLUMN': { + if (!config.columnId) return; + const column = await tx.column.findFirst({ + where: { id: config.columnId, boardId } + }); + if (!column) return; + + await tx.card.update({ + where: { id: cardId }, + data: { columnId: config.columnId } + }); + + // Potentially trigger CARD_MOVED automation + await runAutomations(tx, boardId, 'CARD_MOVED', { + ...context, + toColumnId: config.columnId + }, depth + 1); + break; + } + } +} diff --git a/src/lib/server/notifications.ts b/src/lib/server/notifications.ts index 29f5821..8b76dfa 100644 --- a/src/lib/server/notifications.ts +++ b/src/lib/server/notifications.ts @@ -33,3 +33,30 @@ export async function notify(tx: TxClient, opts: NotifyOptions) { return notification; } + +/** + * Notify all watchers of a card, excluding a set of already-notified users. + */ +export async function notifyWatchers( + tx: TxClient, + cardId: string, + excludeUserIds: string[], + title: string, + link?: string +) { + const watchers = await tx.cardWatcher.findMany({ + where: { cardId }, + select: { userId: true } + }); + + const excludeSet = new Set(excludeUserIds); + for (const watcher of watchers) { + if (excludeSet.has(watcher.userId)) continue; + await notify(tx, { + userId: watcher.userId, + type: 'card_watched', + title, + link + }); + } +} diff --git a/src/lib/server/tenant.ts b/src/lib/server/tenant.ts index af68b5f..c09305c 100644 --- a/src/lib/server/tenant.ts +++ b/src/lib/server/tenant.ts @@ -8,6 +8,8 @@ export interface TenantInfo { id: string; name: string; slug: string; + logoUrl?: string | null; + logoText?: string | null; theme?: TenantThemeData | null; } @@ -32,6 +34,8 @@ export async function resolveTenant(hostname: string): Promise {}); +} + +async function doDispatch(tenantId: string, event: string, data: any) { + const webhooks = await prisma.webhook.findMany({ + where: { tenantId, active: true, events: { has: event } } + }); + + const payload = JSON.stringify({ event, data, timestamp: new Date().toISOString() }); + + for (const webhook of webhooks) { + const signature = crypto.createHmac('sha256', webhook.secret).update(payload).digest('hex'); + + fetch(webhook.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Pounce-Signature': signature, + 'X-Pounce-Event': event + }, + body: payload + }).catch(() => {}); + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 4b3cd27..6463f38 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -29,13 +29,17 @@
- - - - - - - {data.tenant?.name ?? 'Pounce'} + {#if data.tenant?.logoUrl} + + {:else} + + + + + + + {/if} + {data.tenant?.logoText ?? data.tenant?.name ?? 'Pounce'} {#if data.user} @@ -46,6 +50,14 @@ + +
+
+

Invite Links

+
+ {#if !invitesLoaded} + + {/if} + +
+
+ {#if invitesLoaded} +
+ {#if invites.length === 0} +

No invite links yet

+ {:else} +
+ {#each invites as invite (invite.id)} + {@const expired = new Date(invite.expiresAt) < new Date()} +
+ {invite.code} + + {invite.role} · {invite.usedCount}{invite.maxUses ? `/${invite.maxUses}` : ''} uses + + {#if expired} + Expired + {:else} + + Expires {formatDate(invite.expiresAt)} + + {/if} +
+ + +
+
+ {/each} +
+ {/if} +
+ {/if} +
+ + +
+
+

Webhooks

+ {#if !webhooksLoaded} + + {/if} +
+ {#if webhooksLoaded} +
+ + { e.preventDefault(); createWebhook(); }} + class="mb-4 space-y-3" + > +
+ + +
+
+ +
+ {#each availableEvents as event} + + {/each} +
+
+ + + + + {#if webhooks.length === 0} +

No webhooks yet

+ {:else} +
+ {#each webhooks as webhook (webhook.id)} +
+
+
{webhook.url}
+
+ {#each webhook.events as event} + {event} + {/each} +
+
+ + +
+ {/each} +
+ {/if} +
+ {/if} +
+

Categories

diff --git a/src/routes/admin/branding/+page.server.ts b/src/routes/admin/branding/+page.server.ts index ac54462..d2c3009 100644 --- a/src/routes/admin/branding/+page.server.ts +++ b/src/routes/admin/branding/+page.server.ts @@ -8,9 +8,19 @@ export const load: PageServerLoad = async ({ locals }) => { if (!tenant) throw error(400, 'Unknown tenant'); requireTenantAdmin(locals.user); - const theme = await withTenant(tenant.id, (tx) => - tx.tenantTheme.findUnique({ where: { tenantId: tenant.id } }) - ); + const [theme, tenantData] = await withTenant(tenant.id, async (tx) => { + const t = await tx.tenantTheme.findUnique({ where: { tenantId: tenant.id } }); + const td = await tx.tenant.findUnique({ + where: { id: tenant.id }, + select: { logoUrl: true, logoText: true } + }); + return [t, td] as const; + }); - return { theme: theme ?? null, tenantName: tenant.name }; + return { + theme: theme ?? null, + tenantName: tenant.name, + logoUrl: tenantData?.logoUrl ?? null, + logoText: tenantData?.logoText ?? null + }; }; diff --git a/src/routes/admin/branding/+page.svelte b/src/routes/admin/branding/+page.svelte index 4154e3b..2b83052 100644 --- a/src/routes/admin/branding/+page.svelte +++ b/src/routes/admin/branding/+page.svelte @@ -13,6 +13,7 @@ let activeTab = $state<'light' | 'dark'>('light'); let saving = $state(false); + let logoText = $state(data.logoText ?? ''); // Draft theme — initialize from server data let draft = $state(data.theme ? { ...data.theme } : {}); @@ -51,7 +52,7 @@ saving = true; const { ok } = await api('/api/admin/branding', { method: 'PUT', - body: JSON.stringify(draft) + body: JSON.stringify({ ...draft, logoText: logoText || null }) }); if (ok) { toast.success('Theme saved'); @@ -140,6 +141,30 @@
+ +
+

Logo & Identity

+
+ { /* saved via image upload endpoint */ }} + /> +
+ + +

Custom text shown next to the logo in the nav bar

+
+
+
+

Page Background

diff --git a/src/routes/api/admin/branding/+server.ts b/src/routes/api/admin/branding/+server.ts index 2319247..6f55174 100644 --- a/src/routes/api/admin/branding/+server.ts +++ b/src/routes/api/admin/branding/+server.ts @@ -86,6 +86,17 @@ export const PUT: RequestHandler = async ({ request, locals }) => { } } + // Handle logoText on Tenant model (not TenantTheme) + if ('logoText' in body) { + const logoText = body.logoText === null || body.logoText === '' ? null : String(body.logoText).slice(0, 100); + await withTenant(tenant.id, (tx) => + tx.tenant.update({ + where: { id: tenant.id }, + data: { logoText } + }) + ); + } + const theme = await withTenant(tenant.id, (tx) => tx.tenantTheme.upsert({ where: { tenantId: tenant.id }, diff --git a/src/routes/api/admin/branding/images/+server.ts b/src/routes/api/admin/branding/images/+server.ts index 703246c..ff444dd 100644 --- a/src/routes/api/admin/branding/images/+server.ts +++ b/src/routes/api/admin/branding/images/+server.ts @@ -8,14 +8,15 @@ import { join } from 'path'; const MAX_SIZE = 5 * 1024 * 1024; // 5MB const UPLOAD_ROOT = '/app/uploads'; -const VALID_SLOTS = ['light-page-bg', 'light-nav-bg', 'dark-page-bg', 'dark-nav-bg'] as const; +const VALID_SLOTS = ['light-page-bg', 'light-nav-bg', 'dark-page-bg', 'dark-nav-bg', 'logo'] as const; type Slot = typeof VALID_SLOTS[number]; const SLOT_TO_FIELD: Record = { 'light-page-bg': 'lightPageBgImage', 'light-nav-bg': 'lightNavBgImage', 'dark-page-bg': 'darkPageBgImage', - 'dark-nav-bg': 'darkNavBgImage' + 'dark-nav-bg': 'darkNavBgImage', + 'logo': 'logoUrl' }; export const POST: RequestHandler = async ({ request, locals }) => { @@ -42,13 +43,22 @@ export const POST: RequestHandler = async ({ request, locals }) => { const imageUrl = `/api/branding/${tenant.id}/${slot}`; const field = SLOT_TO_FIELD[slot as Slot]; - await withTenant(tenant.id, (tx) => - tx.tenantTheme.upsert({ - where: { tenantId: tenant.id }, - create: { tenantId: tenant.id, [field]: imageUrl }, - update: { [field]: imageUrl } - }) - ); + if (slot === 'logo') { + await withTenant(tenant.id, (tx) => + tx.tenant.update({ + where: { id: tenant.id }, + data: { logoUrl: imageUrl } + }) + ); + } else { + await withTenant(tenant.id, (tx) => + tx.tenantTheme.upsert({ + where: { tenantId: tenant.id }, + create: { tenantId: tenant.id, [field]: imageUrl }, + update: { [field]: imageUrl } + }) + ); + } clearTenantCache(); @@ -73,13 +83,22 @@ export const DELETE: RequestHandler = async ({ request, locals }) => { const field = SLOT_TO_FIELD[slot as Slot]; - await withTenant(tenant.id, (tx) => - tx.tenantTheme.upsert({ - where: { tenantId: tenant.id }, - create: { tenantId: tenant.id, [field]: null }, - update: { [field]: null } - }) - ); + if (slot === 'logo') { + await withTenant(tenant.id, (tx) => + tx.tenant.update({ + where: { id: tenant.id }, + data: { logoUrl: null } + }) + ); + } else { + await withTenant(tenant.id, (tx) => + tx.tenantTheme.upsert({ + where: { tenantId: tenant.id }, + create: { tenantId: tenant.id, [field]: null }, + update: { [field]: null } + }) + ); + } clearTenantCache(); diff --git a/src/routes/api/admin/invites/+server.ts b/src/routes/api/admin/invites/+server.ts new file mode 100644 index 0000000..73182cf --- /dev/null +++ b/src/routes/api/admin/invites/+server.ts @@ -0,0 +1,51 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireTenantAdmin } from '$lib/server/guards.js'; +import crypto from 'crypto'; + +// List all invites for the current tenant +export const GET: RequestHandler = async ({ locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireTenantAdmin(locals.user); + + const invites = await withTenant(tenant.id, async (tx) => { + return tx.tenantInvite.findMany({ + where: { tenantId: tenant.id }, + orderBy: { expiresAt: 'desc' } + }); + }); + + return json({ invites }); +}; + +// Create a new invite +export const POST: RequestHandler = async ({ locals, request }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireTenantAdmin(locals.user); + + const body = await request.json().catch(() => ({})); + const role = body.role === 'ADMIN' ? 'ADMIN' : 'MEMBER'; + const maxUses = typeof body.maxUses === 'number' && body.maxUses > 0 ? body.maxUses : null; + const expiresInDays = typeof body.expiresInDays === 'number' && body.expiresInDays > 0 ? body.expiresInDays : 7; + + const code = crypto.randomBytes(9).toString('base64url'); + const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); + + const invite = await withTenant(tenant.id, async (tx) => { + return tx.tenantInvite.create({ + data: { + tenantId: tenant.id, + code, + role, + expiresAt, + createdBy: locals.user!.id, + maxUses + } + }); + }); + + return json({ invite }, { status: 201 }); +}; diff --git a/src/routes/api/admin/invites/[inviteId]/+server.ts b/src/routes/api/admin/invites/[inviteId]/+server.ts new file mode 100644 index 0000000..eaafd28 --- /dev/null +++ b/src/routes/api/admin/invites/[inviteId]/+server.ts @@ -0,0 +1,24 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireTenantAdmin } from '$lib/server/guards.js'; + +// Delete an invite +export const DELETE: RequestHandler = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireTenantAdmin(locals.user); + + await withTenant(tenant.id, async (tx) => { + const invite = await tx.tenantInvite.findFirst({ + where: { id: params.inviteId, tenantId: tenant.id } + }); + if (!invite) throw error(404, 'Invite not found'); + + await tx.tenantInvite.delete({ + where: { id: params.inviteId } + }); + }); + + return json({ success: true }); +}; diff --git a/src/routes/api/admin/webhooks/+server.ts b/src/routes/api/admin/webhooks/+server.ts new file mode 100644 index 0000000..c2d6772 --- /dev/null +++ b/src/routes/api/admin/webhooks/+server.ts @@ -0,0 +1,50 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireTenantAdmin } from '$lib/server/guards.js'; +import crypto from 'crypto'; + +// List all webhooks for the current tenant +export const GET: RequestHandler = async ({ locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireTenantAdmin(locals.user); + + const webhooks = await withTenant(tenant.id, async (tx) => { + return tx.webhook.findMany({ + where: { tenantId: tenant.id }, + orderBy: { url: 'asc' } + }); + }); + + return json({ webhooks }); +}; + +// Create a new webhook +export const POST: RequestHandler = async ({ locals, request }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireTenantAdmin(locals.user); + + const body = await request.json().catch(() => ({})); + const { url, events, secret } = body; + + if (!url || typeof url !== 'string') throw error(400, 'URL is required'); + if (!events || !Array.isArray(events) || events.length === 0) throw error(400, 'At least one event is required'); + + const webhookSecret = secret && typeof secret === 'string' ? secret : crypto.randomBytes(32).toString('hex'); + + const webhook = await withTenant(tenant.id, async (tx) => { + return tx.webhook.create({ + data: { + tenantId: tenant.id, + url, + events, + secret: webhookSecret, + active: true + } + }); + }); + + return json({ webhook }, { status: 201 }); +}; diff --git a/src/routes/api/admin/webhooks/[webhookId]/+server.ts b/src/routes/api/admin/webhooks/[webhookId]/+server.ts new file mode 100644 index 0000000..3c75c20 --- /dev/null +++ b/src/routes/api/admin/webhooks/[webhookId]/+server.ts @@ -0,0 +1,52 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireTenantAdmin } from '$lib/server/guards.js'; + +// Update a webhook +export const PATCH: RequestHandler = async ({ params, locals, request }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireTenantAdmin(locals.user); + + const body = await request.json().catch(() => ({})); + const data: Record = {}; + + if (typeof body.url === 'string') data.url = body.url; + if (Array.isArray(body.events)) data.events = body.events; + if (typeof body.active === 'boolean') data.active = body.active; + + const webhook = await withTenant(tenant.id, async (tx) => { + const existing = await tx.webhook.findFirst({ + where: { id: params.webhookId, tenantId: tenant.id } + }); + if (!existing) throw error(404, 'Webhook not found'); + + return tx.webhook.update({ + where: { id: params.webhookId }, + data + }); + }); + + return json({ webhook }); +}; + +// Delete a webhook +export const DELETE: RequestHandler = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireTenantAdmin(locals.user); + + await withTenant(tenant.id, async (tx) => { + const webhook = await tx.webhook.findFirst({ + where: { id: params.webhookId, tenantId: tenant.id } + }); + if (!webhook) throw error(404, 'Webhook not found'); + + await tx.webhook.delete({ + where: { id: params.webhookId } + }); + }); + + return json({ success: true }); +}; diff --git a/src/routes/api/boards/+server.ts b/src/routes/api/boards/+server.ts index 33ca21e..38b8d52 100644 --- a/src/routes/api/boards/+server.ts +++ b/src/routes/api/boards/+server.ts @@ -53,6 +53,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { } const { title, description, visibility, categoryId, templateId } = result.data; + const cardPrefix = title.replace(/[^a-zA-Z]/g, '').toUpperCase().slice(0, 4) || 'CARD'; // Load template columns if templateId provided let templateColumns: { title: string }[] = []; @@ -77,6 +78,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { title, description, visibility, + cardPrefix, ...(categoryId ? { categoryId } : {}), members: { create: { diff --git a/src/routes/api/boards/[boardId]/automations/+server.ts b/src/routes/api/boards/[boardId]/automations/+server.ts new file mode 100644 index 0000000..b41006b --- /dev/null +++ b/src/routes/api/boards/[boardId]/automations/+server.ts @@ -0,0 +1,58 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireBoardEditor, requireBoardView } from '$lib/server/guards.js'; +import { canViewBoard } from '$lib/server/permissions.js'; + +const VALID_TRIGGERS = ['CARD_MOVED', 'DUE_PASSED', 'LABEL_ADDED', 'CARD_CREATED']; +const VALID_ACTIONS = ['ADD_LABEL', 'REMOVE_LABEL', 'ASSIGN', 'SET_DUE', 'MOVE_TO_COLUMN']; + +export const GET: RequestHandler = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const rules = await withTenant(tenant.id, async (tx) => { + const board = await tx.board.findFirst({ + where: { id: params.boardId, tenantId: tenant.id }, + include: { members: true } + }); + if (!board) throw error(404, 'Board not found'); + if (!canViewBoard(locals.user, board.visibility, board.members)) { + throw error(403, 'Access denied'); + } + + return tx.automationRule.findMany({ + where: { boardId: params.boardId }, + orderBy: { trigger: 'asc' } + }); + }); + + return json({ rules }); +}; + +export const POST: RequestHandler = async ({ params, request, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const body = await request.json(); + const { trigger, triggerConfig, action, actionConfig } = body; + + if (!trigger || !VALID_TRIGGERS.includes(trigger)) throw error(400, 'Invalid trigger'); + if (!action || !VALID_ACTIONS.includes(action)) throw error(400, 'Invalid action'); + + const rule = await withTenant(tenant.id, async (tx) => { + await requireBoardEditor(tx, locals.user, params.boardId, tenant.id); + + return tx.automationRule.create({ + data: { + boardId: params.boardId, + trigger, + triggerConfig: triggerConfig || null, + action, + actionConfig: actionConfig || null + } + }); + }); + + return json({ rule }, { status: 201 }); +}; diff --git a/src/routes/api/boards/[boardId]/automations/[ruleId]/+server.ts b/src/routes/api/boards/[boardId]/automations/[ruleId]/+server.ts new file mode 100644 index 0000000..f8fb116 --- /dev/null +++ b/src/routes/api/boards/[boardId]/automations/[ruleId]/+server.ts @@ -0,0 +1,50 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireBoardEditor } from '$lib/server/guards.js'; + +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const body = await request.json(); + + const rule = await withTenant(tenant.id, async (tx) => { + await requireBoardEditor(tx, locals.user, params.boardId, tenant.id); + + const existing = await tx.automationRule.findFirst({ + where: { id: params.ruleId, boardId: params.boardId } + }); + if (!existing) throw error(404, 'Rule not found'); + + const data: Record = {}; + if (body.active !== undefined) data.active = body.active; + if (body.triggerConfig !== undefined) data.triggerConfig = body.triggerConfig; + if (body.actionConfig !== undefined) data.actionConfig = body.actionConfig; + + return tx.automationRule.update({ + where: { id: params.ruleId }, + data + }); + }); + + return json({ rule }); +}; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + await withTenant(tenant.id, async (tx) => { + await requireBoardEditor(tx, locals.user, params.boardId, tenant.id); + + const existing = await tx.automationRule.findFirst({ + where: { id: params.ruleId, boardId: params.boardId } + }); + if (!existing) throw error(404, 'Rule not found'); + + await tx.automationRule.delete({ where: { id: params.ruleId } }); + }); + + return json({ ok: true }); +}; diff --git a/src/routes/api/boards/[boardId]/cards/bulk/+server.ts b/src/routes/api/boards/[boardId]/cards/bulk/+server.ts new file mode 100644 index 0000000..9d2492e --- /dev/null +++ b/src/routes/api/boards/[boardId]/cards/bulk/+server.ts @@ -0,0 +1,140 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireBoardEditor } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; +import { logActivity } from '$lib/server/activity.js'; + +const VALID_ACTIONS = ['move', 'archive', 'delete', 'addLabel', 'assign'] as const; +type BulkAction = (typeof VALID_ACTIONS)[number]; + +export const POST: RequestHandler = async ({ params, request, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const body = await request.json(); + const { action, cardIds, columnId, tagId, userId } = body as { + action: string; + cardIds: string[]; + columnId?: string; + tagId?: string; + userId?: string; + }; + + if (!action || !VALID_ACTIONS.includes(action as BulkAction)) { + throw error(400, `Invalid action. Must be one of: ${VALID_ACTIONS.join(', ')}`); + } + if (!Array.isArray(cardIds) || cardIds.length === 0) { + throw error(400, 'cardIds must be a non-empty array'); + } + if (action === 'move' && !columnId) { + throw error(400, 'columnId is required for move action'); + } + if (action === 'addLabel' && !tagId) { + throw error(400, 'tagId is required for addLabel action'); + } + if (action === 'assign' && !userId) { + throw error(400, 'userId is required for assign action'); + } + + const count = await withTenant(tenant.id, async (tx) => { + await requireBoardEditor(tx, locals.user, params.boardId, tenant.id); + + // Verify all cards belong to this board + const cards = await tx.card.findMany({ + where: { + id: { in: cardIds }, + column: { board: { id: params.boardId, tenantId: tenant.id } } + }, + select: { id: true, columnId: true } + }); + + const validCardIds = cards.map((c) => c.id); + if (validCardIds.length === 0) { + throw error(404, 'No matching cards found'); + } + + let affected = 0; + + switch (action as BulkAction) { + case 'move': { + // Verify target column belongs to this board + const column = await tx.column.findFirst({ + where: { id: columnId, board: { id: params.boardId, tenantId: tenant.id } } + }); + if (!column) throw error(404, 'Target column not found'); + + const result = await tx.card.updateMany({ + where: { id: { in: validCardIds } }, + data: { columnId: columnId! } + }); + affected = result.count; + break; + } + + case 'archive': { + const result = await tx.card.updateMany({ + where: { id: { in: validCardIds } }, + data: { archived: true } + }); + affected = result.count; + break; + } + + case 'delete': { + const result = await tx.card.deleteMany({ + where: { id: { in: validCardIds } } + }); + affected = result.count; + break; + } + + case 'addLabel': { + const tag = await tx.tag.findFirst({ + where: { id: tagId, tenantId: tenant.id } + }); + if (!tag) throw error(404, 'Tag not found'); + + const result = await tx.cardLabel.createMany({ + data: validCardIds.map((cardId) => ({ cardId, tagId: tagId! })), + skipDuplicates: true + }); + affected = result.count; + break; + } + + case 'assign': { + const member = await tx.boardMember.findFirst({ + where: { boardId: params.boardId, userId: userId } + }); + if (!member) throw error(400, 'User is not a board member'); + + const result = await tx.cardAssignee.createMany({ + data: validCardIds.map((cardId) => ({ cardId, userId: userId! })), + skipDuplicates: true + }); + affected = result.count; + break; + } + } + + await logActivity(tx, { + boardId: params.boardId, + userId: locals.user!.id, + action: `bulk.${action}`, + metadata: { cardIds: validCardIds, count: affected } + }); + + return affected; + }); + + // Broadcast individual events per card + const socketId = request.headers.get('X-Socket-ID'); + const event = action === 'delete' ? 'card:deleted' : 'card:updated'; + for (const cardId of cardIds) { + broadcast(params.boardId, event, { cardId, boardId: params.boardId, socketId }); + } + broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId }); + + return json({ ok: true, count }); +}; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts index bc572a5..9a5304c 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts @@ -6,6 +6,7 @@ import { withTenant } from '$lib/server/rls.js'; import { requireColumnEditor } from '$lib/server/guards.js'; import { broadcast } from '$lib/server/realtime.js'; import { logActivity } from '$lib/server/activity.js'; +import { runAutomations } from '$lib/server/automations.js'; // Create card in column export const POST: RequestHandler = async ({ params, request, locals }) => { @@ -29,12 +30,21 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { const position = generatePosition(lastCard?.position, null); + // Assign card number + const board = await tx.board.update({ + where: { id: params.boardId }, + data: { nextCardNum: { increment: 1 } }, + select: { nextCardNum: true } + }); + const cardNumber = board.nextCardNum - 1; + const newCard = await tx.card.create({ data: { columnId: params.columnId, title: result.data.title, description: result.data.description, - position + position, + cardNumber }, include: { assignees: { @@ -53,6 +63,12 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { metadata: { title: result.data.title } }); + // Run automations for CARD_CREATED + await runAutomations(tx, params.boardId, 'CARD_CREATED', { + cardId: newCard.id, + cardTitle: newCard.title + }); + return newCard; }); diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts index b86b8f1..e75f98f 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts @@ -6,6 +6,8 @@ import { requireBoardView, requireCardEditor } from '$lib/server/guards.js'; import { canViewBoard } from '$lib/server/permissions.js'; import { broadcast } from '$lib/server/realtime.js'; import { logActivity } from '$lib/server/activity.js'; +import { notifyWatchers } from '$lib/server/notifications.js'; +import { runAutomations } from '$lib/server/automations.js'; // Get single card with full details export const GET: RequestHandler = async ({ params, locals }) => { @@ -33,6 +35,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { include: { user: { select: { id: true, name: true, avatarUrl: true } } } }, attachments: { orderBy: { createdAt: 'desc' } }, + watchers: { select: { userId: true } }, column: { select: { id: true, @@ -103,6 +106,14 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { cardId: params.cardId, metadata: { from: oldCard?.column.title, to: moved.column.title } }); + + // Run automations for CARD_MOVED + await runAutomations(tx, params.boardId, 'CARD_MOVED', { + cardId: params.cardId, + cardTitle: moved.title, + fromColumnId: oldCard?.columnId, + toColumnId: body.columnId + }); } const { column: _col, ...cardData } = moved; @@ -116,7 +127,10 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { } const updateData: Record = { ...result.data }; - if (body.dueDate !== undefined) updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null; + if (body.dueDate !== undefined) { + updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null; + updateData.dueReminderSent = false; + } if (body.archived !== undefined) updateData.archived = body.archived; const updated = await tx.card.update({ @@ -149,6 +163,15 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { }); } + // Notify watchers + await notifyWatchers( + tx, + params.cardId, + [locals.user!.id], + `"${updated.title}" was updated`, + `/boards/${params.boardId}?card=${params.cardId}` + ); + return updated; }); diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/copy/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/copy/+server.ts new file mode 100644 index 0000000..1dc134f --- /dev/null +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/copy/+server.ts @@ -0,0 +1,130 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireCardEditor } from '$lib/server/guards.js'; +import { generatePosition } from '$lib/utils/fractional-index.js'; +import { broadcast } from '$lib/server/realtime.js'; +import { logActivity } from '$lib/server/activity.js'; + +export const POST: RequestHandler = async ({ params, request, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const card = await withTenant(tenant.id, async (tx) => { + await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id); + + // Fetch full card data + const original = await tx.card.findFirst({ + where: { id: params.cardId }, + include: { + labels: true, + assignees: true, + checklists: { + include: { items: { orderBy: { position: 'asc' } } }, + orderBy: { position: 'asc' } + } + } + }); + if (!original) throw error(404, 'Card not found'); + + // Position after original + const nextCard = await tx.card.findFirst({ + where: { + columnId: original.columnId, + position: { gt: original.position } + }, + orderBy: { position: 'asc' } + }); + const position = generatePosition(original.position, nextCard?.position ?? null); + + // Assign card number + const board = await tx.board.update({ + where: { id: params.boardId }, + data: { nextCardNum: { increment: 1 } }, + select: { nextCardNum: true } + }); + const cardNumber = board.nextCardNum - 1; + + // Create the copy + const newCard = await tx.card.create({ + data: { + columnId: original.columnId, + title: `Copy of ${original.title}`, + description: original.description, + dueDate: original.dueDate, + position, + cardNumber + } + }); + + // Copy labels + if (original.labels.length > 0) { + await tx.cardLabel.createMany({ + data: original.labels.map((l) => ({ + cardId: newCard.id, + tagId: l.tagId + })) + }); + } + + // Copy assignees + if (original.assignees.length > 0) { + await tx.cardAssignee.createMany({ + data: original.assignees.map((a) => ({ + cardId: newCard.id, + userId: a.userId + })) + }); + } + + // Copy checklists + items (all unchecked) + for (const cl of original.checklists) { + const newCl = await tx.checklist.create({ + data: { + cardId: newCard.id, + title: cl.title, + position: cl.position + } + }); + if (cl.items.length > 0) { + await tx.checklistItem.createMany({ + data: cl.items.map((item) => ({ + checklistId: newCl.id, + title: item.title, + completed: false, + position: item.position + })) + }); + } + } + + await logActivity(tx, { + boardId: params.boardId, + userId: locals.user!.id, + action: 'card.copied', + cardId: newCard.id, + metadata: { originalId: original.id, title: newCard.title } + }); + + // Return full card for UI + return tx.card.findFirst({ + where: { id: newCard.id }, + include: { + assignees: { + include: { user: { select: { id: true, name: true, avatarUrl: true } } } + }, + labels: { include: { tag: true } }, + checklists: { + select: { id: true, items: { select: { completed: true } } } + }, + _count: { select: { comments: true, attachments: true } } + } + }); + }); + + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:created', { card, columnId: params.columnId, socketId }); + broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId }); + + return json({ card }, { status: 201 }); +}; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/custom-fields/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/custom-fields/+server.ts new file mode 100644 index 0000000..866e588 --- /dev/null +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/custom-fields/+server.ts @@ -0,0 +1,49 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireCardEditor } from '$lib/server/guards.js'; + +export const GET: RequestHandler = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const values = await withTenant(tenant.id, async (tx) => { + return tx.customFieldValue.findMany({ + where: { cardId: params.cardId }, + include: { field: true } + }); + }); + + return json({ values }); +}; + +export const PUT: RequestHandler = async ({ params, request, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const body = await request.json(); + const { fieldId, value } = body; + + if (!fieldId) throw error(400, 'fieldId is required'); + + const result = await withTenant(tenant.id, async (tx) => { + await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id); + + if (value === null || value === '') { + // Remove value + await tx.customFieldValue.deleteMany({ + where: { cardId: params.cardId, fieldId } + }); + return null; + } + + return tx.customFieldValue.upsert({ + where: { cardId_fieldId: { cardId: params.cardId, fieldId } }, + create: { cardId: params.cardId, fieldId, value: String(value) }, + update: { value: String(value) }, + include: { field: true } + }); + }); + + return json({ value: result }); +}; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/watch/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/watch/+server.ts new file mode 100644 index 0000000..09aa21b --- /dev/null +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/watch/+server.ts @@ -0,0 +1,47 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireAuth } from '$lib/server/guards.js'; +import { canViewBoard } from '$lib/server/permissions.js'; + +export const POST: RequestHandler = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireAuth(locals.user); + + await withTenant(tenant.id, async (tx) => { + const card = await tx.card.findFirst({ + where: { + id: params.cardId, + column: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } } + }, + include: { column: { select: { board: { select: { visibility: true, members: true } } } } } + }); + if (!card) throw error(404, 'Card not found'); + if (!canViewBoard(locals.user, card.column.board.visibility, card.column.board.members)) { + throw error(403, 'Access denied'); + } + + await tx.cardWatcher.upsert({ + where: { cardId_userId: { cardId: params.cardId, userId: locals.user!.id } }, + create: { cardId: params.cardId, userId: locals.user!.id }, + update: {} + }); + }); + + return json({ watching: true }); +}; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireAuth(locals.user); + + await withTenant(tenant.id, async (tx) => { + await tx.cardWatcher.deleteMany({ + where: { cardId: params.cardId, userId: locals.user!.id } + }); + }); + + return json({ watching: false }); +}; diff --git a/src/routes/api/boards/[boardId]/custom-fields/+server.ts b/src/routes/api/boards/[boardId]/custom-fields/+server.ts new file mode 100644 index 0000000..8c5c1b8 --- /dev/null +++ b/src/routes/api/boards/[boardId]/custom-fields/+server.ts @@ -0,0 +1,64 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireBoardEditor, requireBoardView } from '$lib/server/guards.js'; +import { canViewBoard } from '$lib/server/permissions.js'; +import { generatePosition } from '$lib/utils/fractional-index.js'; + +export const GET: RequestHandler = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const fields = await withTenant(tenant.id, async (tx) => { + const board = await tx.board.findFirst({ + where: { id: params.boardId, tenantId: tenant.id }, + include: { members: true } + }); + if (!board) throw error(404, 'Board not found'); + if (!canViewBoard(locals.user, board.visibility, board.members)) { + throw error(403, 'Access denied'); + } + + return tx.customFieldDef.findMany({ + where: { boardId: params.boardId }, + orderBy: { position: 'asc' } + }); + }); + + return json({ fields }); +}; + +export const POST: RequestHandler = async ({ params, request, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const body = await request.json(); + const { name, type, options } = body; + + if (!name || !type) throw error(400, 'Name and type are required'); + const validTypes = ['TEXT', 'NUMBER', 'DATE', 'SELECT', 'MULTI_SELECT']; + if (!validTypes.includes(type)) throw error(400, 'Invalid field type'); + + const field = await withTenant(tenant.id, async (tx) => { + await requireBoardEditor(tx, locals.user, params.boardId, tenant.id); + + const lastField = await tx.customFieldDef.findFirst({ + where: { boardId: params.boardId }, + orderBy: { position: 'desc' } + }); + + const position = generatePosition(lastField?.position ?? null, null); + + return tx.customFieldDef.create({ + data: { + boardId: params.boardId, + name, + type, + options: options || null, + position + } + }); + }); + + return json({ field }, { status: 201 }); +}; diff --git a/src/routes/api/boards/[boardId]/custom-fields/[fieldId]/+server.ts b/src/routes/api/boards/[boardId]/custom-fields/[fieldId]/+server.ts new file mode 100644 index 0000000..88c04bf --- /dev/null +++ b/src/routes/api/boards/[boardId]/custom-fields/[fieldId]/+server.ts @@ -0,0 +1,50 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireBoardEditor } from '$lib/server/guards.js'; + +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const body = await request.json(); + + const field = await withTenant(tenant.id, async (tx) => { + await requireBoardEditor(tx, locals.user, params.boardId, tenant.id); + + const existing = await tx.customFieldDef.findFirst({ + where: { id: params.fieldId, boardId: params.boardId } + }); + if (!existing) throw error(404, 'Field not found'); + + const data: Record = {}; + if (body.name !== undefined) data.name = body.name; + if (body.options !== undefined) data.options = body.options; + if (body.position !== undefined) data.position = body.position; + + return tx.customFieldDef.update({ + where: { id: params.fieldId }, + data + }); + }); + + return json({ field }); +}; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + await withTenant(tenant.id, async (tx) => { + await requireBoardEditor(tx, locals.user, params.boardId, tenant.id); + + const existing = await tx.customFieldDef.findFirst({ + where: { id: params.fieldId, boardId: params.boardId } + }); + if (!existing) throw error(404, 'Field not found'); + + await tx.customFieldDef.delete({ where: { id: params.fieldId } }); + }); + + return json({ ok: true }); +}; diff --git a/src/routes/api/boards/[boardId]/export/+server.ts b/src/routes/api/boards/[boardId]/export/+server.ts new file mode 100644 index 0000000..55d8b84 --- /dev/null +++ b/src/routes/api/boards/[boardId]/export/+server.ts @@ -0,0 +1,100 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireBoardView } from '$lib/server/guards.js'; + +export const GET: RequestHandler = async ({ params, url, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const format = url.searchParams.get('format') || 'json'; + + const board = await withTenant(tenant.id, async (tx) => { + const b = await requireBoardView(tx, locals.user, params.boardId, tenant.id); + + // Get full data including checklists + const columns = await tx.column.findMany({ + where: { boardId: params.boardId }, + orderBy: { position: 'asc' }, + include: { + cards: { + orderBy: { position: 'asc' }, + include: { + labels: { include: { tag: true } }, + assignees: { include: { user: { select: { name: true, email: true } } } }, + checklists: { + orderBy: { position: 'asc' }, + include: { items: { orderBy: { position: 'asc' } } } + } + } + } + } + }); + + return { + title: b.title, + description: b.description, + visibility: b.visibility, + columns: columns.map((col) => ({ + title: col.title, + cards: col.cards.map((card) => ({ + title: card.title, + description: card.description, + dueDate: card.dueDate, + archived: card.archived, + labels: card.labels.map((l) => ({ + name: l.tag.name, + color: l.tag.color + })), + assignees: card.assignees.map((a) => ({ + name: a.user.name, + email: a.user.email + })), + checklists: card.checklists.map((cl) => ({ + title: cl.title, + items: cl.items.map((item) => ({ + title: item.title, + completed: item.completed + })) + })) + })) + })) + }; + }); + + if (format === 'csv') { + // Flatten cards into CSV rows + const rows: string[][] = [['Column', 'Title', 'Description', 'Due Date', 'Labels', 'Assignees']]; + for (const col of board.columns) { + for (const card of col.cards) { + rows.push([ + col.title, + card.title, + card.description || '', + card.dueDate ? new Date(card.dueDate).toISOString().split('T')[0] : '', + card.labels.map((l: any) => l.name).join('; '), + card.assignees.map((a: any) => a.name).join('; ') + ]); + } + } + + const csv = rows.map((row) => + row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',') + ).join('\n'); + + return new Response(csv, { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="${board.title}.csv"` + } + }); + } + + // JSON export + return new Response(JSON.stringify(board, null, 2), { + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="${board.title}.json"` + } + }); +}; diff --git a/src/routes/api/boards/import/+server.ts b/src/routes/api/boards/import/+server.ts new file mode 100644 index 0000000..7bb056c --- /dev/null +++ b/src/routes/api/boards/import/+server.ts @@ -0,0 +1,201 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireAuth } from '$lib/server/guards.js'; +import { generatePositions } from '$lib/utils/fractional-index.js'; + +const MAX_CARDS = 500; + +interface ImportColumn { + title: string; + cards: ImportCard[]; +} + +interface ImportCard { + title: string; + description?: string; + dueDate?: string | null; + labels?: { name: string; color: string }[]; + checklists?: { + title: string; + items: { title: string; completed?: boolean }[]; + }[]; +} + +export const POST: RequestHandler = async ({ request, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireAuth(locals.user); + + const body = await request.json(); + const { data: importData, format } = body; + + if (!importData) throw error(400, 'No import data provided'); + + let columns: ImportColumn[]; + let boardTitle: string; + + if (format === 'trello') { + // Trello JSON import + boardTitle = importData.name || 'Imported Board'; + const listsById = new Map(); + columns = (importData.lists || []).map((list: any) => { + listsById.set(list.id, list.name); + return { title: list.name, cards: [] }; + }); + + const trelloCards = importData.cards || []; + if (trelloCards.length > MAX_CARDS) { + throw error(400, `Too many cards (max ${MAX_CARDS})`); + } + + for (const tc of trelloCards) { + if (tc.closed) continue; + const colIdx = columns.findIndex((c: ImportColumn) => c.title === listsById.get(tc.idList)); + if (colIdx === -1) continue; + + const card: ImportCard = { + title: tc.name, + description: tc.desc || undefined, + dueDate: tc.due || null, + labels: (tc.labels || []).map((l: any) => ({ + name: l.name || l.color, + color: l.color ? `#${l.color}` : '#6b7280' + })), + checklists: [] + }; + + // Map Trello checklists + if (importData.checklists) { + const cardChecklists = importData.checklists.filter((cl: any) => cl.idCard === tc.id); + card.checklists = cardChecklists.map((cl: any) => ({ + title: cl.name, + items: (cl.checkItems || []).map((item: any) => ({ + title: item.name, + completed: item.state === 'complete' + })) + })); + } + + columns[colIdx].cards.push(card); + } + } else { + // Pounce JSON import + boardTitle = importData.title || 'Imported Board'; + columns = importData.columns || []; + + const totalCards = columns.reduce((sum: number, col: ImportColumn) => sum + col.cards.length, 0); + if (totalCards > MAX_CARDS) { + throw error(400, `Too many cards (max ${MAX_CARDS})`); + } + } + + const board = await withTenant(tenant.id, async (tx) => { + const cardPrefix = boardTitle.replace(/[^a-zA-Z]/g, '').toUpperCase().slice(0, 4) || 'CARD'; + + // Create the board + const newBoard = await tx.board.create({ + data: { + tenantId: tenant.id, + title: boardTitle, + cardPrefix, + members: { + create: { userId: locals.user!.id, role: 'OWNER' } + } + } + }); + + // Get existing tags for this tenant + const existingTags = await tx.tag.findMany({ + where: { tenantId: tenant.id } + }); + const tagMap = new Map(existingTags.map((t) => [t.name.toLowerCase(), t])); + + const colPositions = generatePositions(columns.length); + let nextCardNum = 1; + + for (let ci = 0; ci < columns.length; ci++) { + const col = columns[ci]; + const newCol = await tx.column.create({ + data: { + boardId: newBoard.id, + title: col.title, + position: colPositions[ci] + } + }); + + const cardPositions = generatePositions(col.cards.length); + + for (let cri = 0; cri < col.cards.length; cri++) { + const card = col.cards[cri]; + const newCard = await tx.card.create({ + data: { + columnId: newCol.id, + title: card.title, + description: card.description, + dueDate: card.dueDate ? new Date(card.dueDate) : null, + position: cardPositions[cri], + cardNumber: nextCardNum++ + } + }); + + // Create labels + if (card.labels && card.labels.length > 0) { + for (const label of card.labels) { + let tag = tagMap.get(label.name.toLowerCase()); + if (!tag) { + tag = await tx.tag.create({ + data: { + tenantId: tenant.id, + name: label.name, + color: label.color || '#6b7280' + } + }); + tagMap.set(label.name.toLowerCase(), tag); + } + await tx.cardLabel.create({ + data: { cardId: newCard.id, tagId: tag.id } + }); + } + } + + // Create checklists + if (card.checklists && card.checklists.length > 0) { + const clPositions = generatePositions(card.checklists.length); + for (let cli = 0; cli < card.checklists.length; cli++) { + const cl = card.checklists[cli]; + const newCl = await tx.checklist.create({ + data: { + cardId: newCard.id, + title: cl.title, + position: clPositions[cli] + } + }); + + if (cl.items.length > 0) { + const itemPositions = generatePositions(cl.items.length); + await tx.checklistItem.createMany({ + data: cl.items.map((item, idx) => ({ + checklistId: newCl.id, + title: item.title, + completed: item.completed || false, + position: itemPositions[idx] + })) + }); + } + } + } + } + } + + // Update the board's nextCardNum + await tx.board.update({ + where: { id: newBoard.id }, + data: { nextCardNum: nextCardNum } + }); + + return newBoard; + }); + + return json({ board: { id: board.id, title: board.title } }, { status: 201 }); +}; diff --git a/src/routes/api/invite/[code]/+server.ts b/src/routes/api/invite/[code]/+server.ts new file mode 100644 index 0000000..e3a4e6c --- /dev/null +++ b/src/routes/api/invite/[code]/+server.ts @@ -0,0 +1,42 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { requireAuth } from '$lib/server/guards.js'; +import { prisma } from '$lib/server/db.js'; + +// Accept an invite +export const POST: RequestHandler = async ({ params, locals }) => { + requireAuth(locals.user); + + const invite = await prisma.tenantInvite.findUnique({ + where: { code: params.code } + }); + + if (!invite) throw error(404, 'Invite not found'); + + if (invite.expiresAt < new Date()) { + throw error(410, 'Invite has expired'); + } + + if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) { + throw error(410, 'Invite has reached its maximum number of uses'); + } + + // Since users are per-tenant, check if the current user's tenant matches + // (they'd already be registered on this tenant to be logged in here) + const user = await prisma.user.findUnique({ + where: { id: locals.user.id }, + select: { tenantId: true } + }); + + if (user && user.tenantId === invite.tenantId) { + return json({ error: 'Already a member' }, { status: 409 }); + } + + // Increment usedCount + await prisma.tenantInvite.update({ + where: { id: invite.id }, + data: { usedCount: { increment: 1 } } + }); + + return json({ role: invite.role, tenantId: invite.tenantId }); +}; diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts index e81c0ad..d428ae6 100644 --- a/src/routes/api/search/+server.ts +++ b/src/routes/api/search/+server.ts @@ -14,6 +14,38 @@ export const GET: RequestHandler = async ({ url, locals }) => { const limit = Math.min(parseInt(url.searchParams.get('limit') || '10'), 50); + // Check for PREFIX-123 pattern + const cardNumMatch = q.match(/^([A-Za-z]+)-(\d+)$/); + if (cardNumMatch) { + const prefix = cardNumMatch[1].toUpperCase(); + const num = parseInt(cardNumMatch[2]); + const results = await withTenant(tenant.id, async (tx) => { + const cards: any[] = await tx.$queryRaw` + SELECT + c.id, + c.title, + c.description, + c.column_id AS "columnId", + col.title AS "columnTitle", + b.id AS "boardId", + b.title AS "boardTitle" + FROM cards c + JOIN columns col ON col.id = c.column_id + JOIN boards b ON b.id = col.board_id + LEFT JOIN board_members bm ON bm.board_id = b.id AND bm.user_id = ${locals.user!.id}::uuid + WHERE b.tenant_id = ${tenant.id}::uuid + AND b.card_prefix = ${prefix} + AND c.card_number = ${num} + AND c.archived = false + AND b.archived = false + AND (b.visibility = 'PUBLIC' OR bm.user_id IS NOT NULL) + LIMIT ${limit} + `; + return cards; + }); + if (results.length > 0) return json({ results }); + } + const results = await withTenant(tenant.id, async (tx) => { const cards: any[] = await tx.$queryRaw` SELECT diff --git a/src/routes/boards/+page.svelte b/src/routes/boards/+page.svelte index 7e1c15b..61878bc 100644 --- a/src/routes/boards/+page.svelte +++ b/src/routes/boards/+page.svelte @@ -17,6 +17,34 @@ let error = $state(''); let filterCategoryId = $state(''); let showArchived = $state($page.url.searchParams.get('archived') === 'true'); + let importing = $state(false); + let fileInput: HTMLInputElement; + + async function handleImport(e: Event) { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + + importing = true; + try { + const text = await file.text(); + const importData = JSON.parse(text); + const format = importData.lists ? 'trello' : 'pounce'; + + const { ok } = await api('/api/boards/import', { + method: 'POST', + body: JSON.stringify({ data: importData, format }) + }); + if (ok) { + toast.success('Board imported'); + invalidateAll(); + } + } catch { + toast.error('Invalid import file'); + } + importing = false; + input.value = ''; + } const filteredBoards = $derived( filterCategoryId @@ -85,6 +113,20 @@ {showArchived ? 'Showing archived' : 'Show archived'} + +
{/if} + {#if data.canEdit} + + + {/if} + + + + + + Export + +
-
+
{#each columns as column (column.id)} - {@const visibleCards = filterCards(column.cards)} + {@const colSort = columnSorts[column.id] ?? null} + {@const visibleCards = sortCards(filterCards(column.cards), colSort)}
{visibleCards.length}{#if hasActiveFilters}/{column.cards.length}{/if} {/if} - {#if data.canEdit} + (columnSorts[column.id] = sort)} + /> + {#if data.canEdit}
+ {/each}
{/if} @@ -729,3 +856,21 @@ {/if}
+ +{#if selectMode && selectedCardIds.size > 0} + { + selectMode = false; + selectedCardIds = new Set(); + // Reload page data + window.location.reload(); + }} + oncancel={() => { + selectMode = false; + selectedCardIds = new Set(); + }} + /> +{/if} diff --git a/src/routes/boards/[boardId]/calendar/+page.server.ts b/src/routes/boards/[boardId]/calendar/+page.server.ts new file mode 100644 index 0000000..2a7439d --- /dev/null +++ b/src/routes/boards/[boardId]/calendar/+page.server.ts @@ -0,0 +1,35 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireBoardView } from '$lib/server/guards.js'; +import { canEditBoard } from '$lib/server/permissions.js'; + +export const load: PageServerLoad = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const board = await withTenant(tenant.id, async (tx) => { + return requireBoardView(tx, locals.user, params.boardId, tenant.id); + }); + + // Filter to cards with due dates + const cardsWithDue = board.columns.flatMap((col: any) => + col.cards + .filter((c: any) => c.dueDate) + .map((c: any) => ({ + ...c, + columnTitle: col.title, + columnId: col.id + })) + ); + + return { + board: { + id: board.id, + title: board.title, + cardPrefix: (board as any).cardPrefix + }, + cards: cardsWithDue, + canEdit: canEditBoard(locals.user, board.members) + }; +}; diff --git a/src/routes/boards/[boardId]/calendar/+page.svelte b/src/routes/boards/[boardId]/calendar/+page.svelte new file mode 100644 index 0000000..240dae8 --- /dev/null +++ b/src/routes/boards/[boardId]/calendar/+page.svelte @@ -0,0 +1,49 @@ + + + + {data.board.title} - Calendar + + +
+ +
+ + + + + +

{data.board.title}

+ +
+ + Board + + + Calendar + +
+
+ + +
+ {#if data.cards.length === 0} +
+

No cards with due dates.

+ Back to board +
+ {:else} + + {/if} +
+
diff --git a/src/routes/invite/[code]/+page.server.ts b/src/routes/invite/[code]/+page.server.ts new file mode 100644 index 0000000..70e110e --- /dev/null +++ b/src/routes/invite/[code]/+page.server.ts @@ -0,0 +1,32 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types.js'; +import { prisma } from '$lib/server/db.js'; + +export const load: PageServerLoad = async ({ params, locals }) => { + const invite = await prisma.tenantInvite.findUnique({ + where: { code: params.code }, + include: { + tenant: { select: { name: true } } + } + }); + + if (!invite) throw error(404, 'Invite not found'); + + if (invite.expiresAt < new Date()) { + throw error(410, 'This invite has expired'); + } + + if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) { + throw error(410, 'This invite has reached its maximum number of uses'); + } + + return { + invite: { + code: invite.code, + role: invite.role, + tenantName: invite.tenant.name, + expiresAt: invite.expiresAt.toISOString() + }, + user: locals.user + }; +}; diff --git a/src/routes/invite/[code]/+page.svelte b/src/routes/invite/[code]/+page.svelte new file mode 100644 index 0000000..18a51a8 --- /dev/null +++ b/src/routes/invite/[code]/+page.svelte @@ -0,0 +1,93 @@ + + + + Invite to {data.invite.tenantName} + + +
+
+
+

+ You've been invited +

+

+ Join {data.invite.tenantName} + as {data.invite.role === 'ADMIN' ? 'an Admin' : 'a Member'} +

+ + {#if errorMsg} +
+ {errorMsg} +
+ {/if} + + {#if accepted} +
+

Invite accepted!

+ + Go to Boards + +
+ {:else if data.user} + + {:else} + + {/if} + +

+ This invite expires on {new Date(data.invite.expiresAt).toLocaleDateString()} +

+
+
+
diff --git a/src/routes/my-work/+page.server.ts b/src/routes/my-work/+page.server.ts new file mode 100644 index 0000000..7052975 --- /dev/null +++ b/src/routes/my-work/+page.server.ts @@ -0,0 +1,46 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireAuth } from '$lib/server/guards.js'; + +export const load: PageServerLoad = async ({ locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireAuth(locals.user); + + const cards = await withTenant(tenant.id, async (tx) => { + return tx.card.findMany({ + where: { + archived: false, + assignees: { + some: { userId: locals.user!.id } + }, + column: { + board: { + archived: false, + tenantId: tenant.id + } + } + }, + include: { + labels: { include: { tag: true } }, + assignees: { + include: { user: { select: { id: true, name: true, avatarUrl: true } } } + }, + column: { + select: { + id: true, + title: true, + board: { select: { id: true, title: true, cardPrefix: true } } + } + } + }, + orderBy: [ + { dueDate: { sort: 'asc', nulls: 'last' } }, + { updatedAt: 'desc' } + ] + }); + }); + + return { cards }; +}; diff --git a/src/routes/my-work/+page.svelte b/src/routes/my-work/+page.svelte new file mode 100644 index 0000000..10246db --- /dev/null +++ b/src/routes/my-work/+page.svelte @@ -0,0 +1,123 @@ + + + + My Work + + +
+

My Work

+

Cards assigned to you across all boards

+ + {#if data.cards.length === 0} +
+

No cards assigned to you yet.

+ Go to boards +
+ {:else} + + {/if} +