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 @@ + + +
No automation rules
+ {:else} +