@knotpad/app 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/(app)/calendar/page.tsx +57 -0
- package/app/(app)/error.tsx +35 -0
- package/app/(app)/graph/page.tsx +32 -0
- package/app/(app)/guide/page.tsx +21 -0
- package/app/(app)/kanban/loading.tsx +24 -0
- package/app/(app)/kanban/page.tsx +59 -0
- package/app/(app)/layout.tsx +122 -0
- package/app/(app)/list/loading.tsx +21 -0
- package/app/(app)/list/page.tsx +137 -0
- package/app/(app)/loading.tsx +18 -0
- package/app/(app)/notes/[noteId]/page.tsx +84 -0
- package/app/(app)/notes/layout.tsx +30 -0
- package/app/(app)/notes/page.tsx +39 -0
- package/app/(app)/page.tsx +5 -0
- package/app/(app)/settings/agent-token/page.tsx +59 -0
- package/app/(app)/settings/backup/page.tsx +49 -0
- package/app/(app)/settings/billing/page.tsx +53 -0
- package/app/(app)/settings/calendar/page.tsx +41 -0
- package/app/(app)/settings/layout.test.tsx +39 -0
- package/app/(app)/settings/layout.tsx +71 -0
- package/app/(app)/settings/page.tsx +4 -0
- package/app/(app)/settings/security/page.tsx +43 -0
- package/app/(app)/settings/team/page.tsx +74 -0
- package/app/(app)/settings/workspace/page.tsx +27 -0
- package/app/(app)/tasks/[taskId]/page.tsx +79 -0
- package/app/(auth)/forgot-password/page.tsx +106 -0
- package/app/(auth)/guest/page.tsx +56 -0
- package/app/(auth)/layout.tsx +13 -0
- package/app/(auth)/login/page.tsx +14 -0
- package/app/(auth)/register/page.tsx +193 -0
- package/app/(auth)/reset-password/page.tsx +138 -0
- package/app/api/account/claim/route.tsx +135 -0
- package/app/api/admin/backfill-encryption/route.tsx +43 -0
- package/app/api/admin/license/route.tsx +42 -0
- package/app/api/auth/2fa/route.tsx +148 -0
- package/app/api/auth/[...nextauth]/route.tsx +3 -0
- package/app/api/auth/change-password/route.tsx +61 -0
- package/app/api/auth/check-2fa/route.tsx +19 -0
- package/app/api/auth/forgot-password/route.tsx +65 -0
- package/app/api/auth/reset-password/route.tsx +52 -0
- package/app/api/auth/verify-2fa/route.tsx +88 -0
- package/app/api/backup/download/db/route.ts +29 -0
- package/app/api/backup/download/notes/route.ts +25 -0
- package/app/api/backup/settings/route.ts +92 -0
- package/app/api/billing/checkout/route.tsx +81 -0
- package/app/api/billing/migrate/route.tsx +163 -0
- package/app/api/billing/portal/route.tsx +24 -0
- package/app/api/billing/setup-intent/route.tsx +55 -0
- package/app/api/billing/status/route.tsx +36 -0
- package/app/api/billing/subscribe/route.tsx +85 -0
- package/app/api/billing/webhook/route.tsx +199 -0
- package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
- package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
- package/app/api/calendar-feeds/events/route.tsx +82 -0
- package/app/api/calendar-feeds/route.tsx +52 -0
- package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
- package/app/api/cron/calendar-feeds/route.tsx +31 -0
- package/app/api/cron/stale-tasks/route.tsx +51 -0
- package/app/api/cron/sync/route.tsx +34 -0
- package/app/api/devices/[deviceId]/route.tsx +25 -0
- package/app/api/devices/route.tsx +41 -0
- package/app/api/export/route.tsx +40 -0
- package/app/api/feedback/route.tsx +54 -0
- package/app/api/folders/[folderId]/route.tsx +51 -0
- package/app/api/folders/route.tsx +37 -0
- package/app/api/graph/route.tsx +242 -0
- package/app/api/guest/route.tsx +58 -0
- package/app/api/health/route.tsx +10 -0
- package/app/api/holidays/countries/route.tsx +14 -0
- package/app/api/holidays/route.tsx +49 -0
- package/app/api/holidays/states/route.tsx +21 -0
- package/app/api/invites/[token]/route.tsx +131 -0
- package/app/api/invites/route.tsx +74 -0
- package/app/api/mcp/generate-token/route.tsx +55 -0
- package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
- package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
- package/app/api/notes/[noteId]/export/route.tsx +45 -0
- package/app/api/notes/[noteId]/route.tsx +360 -0
- package/app/api/notes/route.tsx +112 -0
- package/app/api/notifications/route.tsx +44 -0
- package/app/api/register/route.tsx +67 -0
- package/app/api/restore/route.tsx +148 -0
- package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
- package/app/api/sync/conflicts/route.tsx +48 -0
- package/app/api/sync/status/route.tsx +49 -0
- package/app/api/sync/trigger/route.tsx +15 -0
- package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
- package/app/api/tasks/[taskId]/route.tsx +259 -0
- package/app/api/tasks/bulk/route.tsx +133 -0
- package/app/api/tasks/route.tsx +36 -0
- package/app/api/workspace/active/route.tsx +39 -0
- package/app/api/workspace/create-team/route.tsx +42 -0
- package/app/api/workspace/kanban-statuses/route.tsx +71 -0
- package/app/api/workspace/members/[memberId]/route.tsx +69 -0
- package/app/api/workspace/route.tsx +24 -0
- package/app/download/page.tsx +170 -0
- package/app/favicon.ico +0 -0
- package/app/generated/prisma/client.d.ts +1 -0
- package/app/generated/prisma/client.js +5 -0
- package/app/generated/prisma/default.d.ts +1 -0
- package/app/generated/prisma/default.js +5 -0
- package/app/generated/prisma/edge.d.ts +1 -0
- package/app/generated/prisma/edge.js +497 -0
- package/app/generated/prisma/index-browser.js +523 -0
- package/app/generated/prisma/index.d.ts +46376 -0
- package/app/generated/prisma/index.js +497 -0
- package/app/generated/prisma/package.json +144 -0
- package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
- package/app/generated/prisma/runtime/client.d.ts +3386 -0
- package/app/generated/prisma/runtime/client.js +86 -0
- package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
- package/app/generated/prisma/runtime/index-browser.js +6 -0
- package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
- package/app/generated/prisma/schema.prisma +456 -0
- package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
- package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
- package/app/globals.css +54 -0
- package/app/invite/[token]/page.tsx +52 -0
- package/app/layout.tsx +90 -0
- package/app/mcp/route.tsx +430 -0
- package/app/opengraph-image.tsx +120 -0
- package/app/page.tsx +398 -0
- package/app/privacy/page.tsx +69 -0
- package/app/robots.tsx +25 -0
- package/app/sitemap.tsx +36 -0
- package/app/terms/page.tsx +69 -0
- package/app/upgrade/page.tsx +75 -0
- package/auth.config.ts +33 -0
- package/auth.ts +79 -0
- package/bin/brief.js +224 -0
- package/components/auth/login-form.tsx +302 -0
- package/components/auth/password-checklist.tsx +31 -0
- package/components/auth/password-input.tsx +36 -0
- package/components/auth/switch-account-button.test.tsx +22 -0
- package/components/auth/switch-account-button.tsx +19 -0
- package/components/auth/two-factor-input.tsx +116 -0
- package/components/billing/billing-dashboard.tsx +265 -0
- package/components/billing/card-form.tsx +210 -0
- package/components/billing/claim-account-form.tsx +99 -0
- package/components/branding/app-logo.test.tsx +20 -0
- package/components/branding/app-logo.tsx +25 -0
- package/components/calendar/calendar-agenda.tsx +150 -0
- package/components/calendar/calendar-drag.test.tsx +177 -0
- package/components/calendar/calendar-grid.tsx +357 -0
- package/components/calendar/calendar-hooks.test.tsx +27 -0
- package/components/calendar/calendar-hooks.ts +351 -0
- package/components/calendar/calendar-toolbar.test.tsx +68 -0
- package/components/calendar/calendar-toolbar.tsx +291 -0
- package/components/calendar/calendar-types.ts +148 -0
- package/components/calendar/calendar-view.test.tsx +295 -0
- package/components/calendar/calendar-view.tsx +307 -0
- package/components/calendar/day-detail-popover.tsx +174 -0
- package/components/calendar/task-chip.tsx +86 -0
- package/components/command/command-palette.test.tsx +33 -0
- package/components/command/command-palette.tsx +310 -0
- package/components/download-cta.tsx +87 -0
- package/components/feedback/feedback-popup.tsx +207 -0
- package/components/graph/graph-draw.ts +337 -0
- package/components/graph/graph-overlays.tsx +160 -0
- package/components/graph/graph-page.test.tsx +131 -0
- package/components/graph/graph-page.tsx +263 -0
- package/components/graph/graph-types.ts +47 -0
- package/components/graph/graph-view.tsx +322 -0
- package/components/guide/guide-view.tsx +522 -0
- package/components/kanban/kanban-board.test.tsx +128 -0
- package/components/kanban/kanban-board.tsx +361 -0
- package/components/kanban/kanban-card-menu.tsx +102 -0
- package/components/kanban/kanban-card.tsx +227 -0
- package/components/kanban/kanban-column.tsx +49 -0
- package/components/kanban/kanban-status-context.tsx +28 -0
- package/components/landing/calendar-sandbox.test.tsx +15 -0
- package/components/landing/calendar-sandbox.tsx +107 -0
- package/components/landing/graph-sandbox.test.tsx +27 -0
- package/components/landing/graph-sandbox.tsx +80 -0
- package/components/landing/kanban-sandbox.test.tsx +24 -0
- package/components/landing/kanban-sandbox.tsx +101 -0
- package/components/landing/landing-showcase.test.tsx +21 -0
- package/components/landing/landing-showcase.tsx +54 -0
- package/components/landing/list-sandbox.tsx +86 -0
- package/components/landing/mock-workspace.ts +168 -0
- package/components/landing/notes-sandbox.test.tsx +14 -0
- package/components/landing/notes-sandbox.tsx +88 -0
- package/components/layout/app-shell.tsx +83 -0
- package/components/layout/backup-scheduler.tsx +122 -0
- package/components/layout/bottom-nav.tsx +43 -0
- package/components/layout/icon-bar.test.tsx +29 -0
- package/components/layout/icon-bar.tsx +118 -0
- package/components/layout/mobile-top-bar.tsx +68 -0
- package/components/layout/notes-panel-folder.tsx +127 -0
- package/components/layout/notes-panel-note-item.tsx +140 -0
- package/components/layout/notes-panel-task-tab.tsx +63 -0
- package/components/layout/notes-panel-types.ts +44 -0
- package/components/layout/notes-panel.tsx +476 -0
- package/components/layout/notification-bell.tsx +251 -0
- package/components/layout/paywall-screen.tsx +41 -0
- package/components/layout/pro-banner.tsx +76 -0
- package/components/layout/sw-register.tsx +27 -0
- package/components/layout/workspace-switcher.tsx +90 -0
- package/components/notes/mobile-bottom-sheet.tsx +99 -0
- package/components/notes/note-editor-context-menu.tsx +47 -0
- package/components/notes/note-editor-dom.ts +33 -0
- package/components/notes/note-editor-dropdowns.tsx +484 -0
- package/components/notes/note-editor-hooks.ts +692 -0
- package/components/notes/note-editor-keyboard.ts +305 -0
- package/components/notes/note-editor-overlay.tsx +90 -0
- package/components/notes/note-editor.test.tsx +372 -0
- package/components/notes/note-editor.tsx +662 -0
- package/components/notes/note-preview-pane.tsx +156 -0
- package/components/notes/note-tabs.tsx +120 -0
- package/components/notes/note-types.tsx +157 -0
- package/components/settings/accept-invite.tsx +108 -0
- package/components/settings/agent-token-settings.tsx +369 -0
- package/components/settings/backup-restore-settings.test.tsx +25 -0
- package/components/settings/backup-restore-settings.tsx +327 -0
- package/components/settings/calendar-feeds-settings.tsx +489 -0
- package/components/settings/calendar-general-settings.tsx +174 -0
- package/components/settings/confirm-danger-action.test.tsx +215 -0
- package/components/settings/confirm-danger-action.tsx +65 -0
- package/components/settings/security-settings.tsx +252 -0
- package/components/settings/settings-guidance.test.tsx +98 -0
- package/components/settings/team-settings.tsx +319 -0
- package/components/settings/two-factor-auth.tsx +296 -0
- package/components/settings/workspace-settings-client.tsx +363 -0
- package/components/settings/workspace-settings-form.tsx +73 -0
- package/components/sync/conflict-viewer.tsx +247 -0
- package/components/sync/sync-indicator.tsx +171 -0
- package/components/tasks/snippet-thread.tsx +119 -0
- package/components/tasks/status-dot.tsx +47 -0
- package/components/tasks/task-badge.tsx +43 -0
- package/components/tasks/task-detail.test.tsx +187 -0
- package/components/tasks/task-detail.tsx +458 -0
- package/components/tasks/task-list-filters.test.tsx +75 -0
- package/components/tasks/task-list-filters.tsx +163 -0
- package/components/tasks/task-list-types.ts +20 -0
- package/components/tasks/task-list.test.tsx +175 -0
- package/components/tasks/task-list.tsx +481 -0
- package/components/tasks/task-row.tsx +85 -0
- package/components/tasks/task-table-row.tsx +259 -0
- package/components/ui/skeleton.tsx +3 -0
- package/components/ui/toast.test.tsx +42 -0
- package/components/ui/toast.tsx +70 -0
- package/instrumentation.tsx +23 -0
- package/lib/api-error.ts +50 -0
- package/lib/backup/backup-runner.test.ts +32 -0
- package/lib/backup/backup-runner.ts +19 -0
- package/lib/backup/backup-schedule.test.ts +23 -0
- package/lib/backup/backup-schedule.ts +55 -0
- package/lib/backup/backup-settings.test.ts +30 -0
- package/lib/backup/backup-settings.ts +27 -0
- package/lib/backup/export-notes-zip.test.ts +26 -0
- package/lib/backup/export-notes-zip.ts +82 -0
- package/lib/backup/export-workspace-backup.test.ts +17 -0
- package/lib/backup/export-workspace-backup.ts +77 -0
- package/lib/backup/restore-workspace-from-export.test.ts +18 -0
- package/lib/backup/restore-workspace-from-export.ts +183 -0
- package/lib/backup/types.ts +14 -0
- package/lib/brand-icons.ts +1 -0
- package/lib/calendar-feed-crypto.ts +38 -0
- package/lib/calendar-feed.ts +239 -0
- package/lib/client/online-status.ts +47 -0
- package/lib/conflict-resolver.test.ts +57 -0
- package/lib/conflict-resolver.ts +240 -0
- package/lib/db-init.ts +79 -0
- package/lib/email.ts +159 -0
- package/lib/encryption.test.ts +41 -0
- package/lib/encryption.ts +98 -0
- package/lib/extract-snippet.test.ts +123 -0
- package/lib/extract-snippet.ts +69 -0
- package/lib/kanban-status.ts +55 -0
- package/lib/license.ts +21 -0
- package/lib/limits.ts +31 -0
- package/lib/mcp-auth.test.ts +58 -0
- package/lib/mcp-auth.ts +65 -0
- package/lib/mcp-contract.test.ts +25 -0
- package/lib/mcp-contract.ts +210 -0
- package/lib/mcp-handler.ts +31 -0
- package/lib/mcp-url.test.ts +12 -0
- package/lib/mcp-url.ts +7 -0
- package/lib/mentions.test.ts +45 -0
- package/lib/mentions.ts +73 -0
- package/lib/note-crypto.ts +108 -0
- package/lib/note-sync.ts +201 -0
- package/lib/note-title.ts +93 -0
- package/lib/prisma.ts +193 -0
- package/lib/pro-flush.ts +292 -0
- package/lib/rate-limit.ts +57 -0
- package/lib/stripe.ts +38 -0
- package/lib/sync-worker.ts +388 -0
- package/lib/task-parser.test.ts +91 -0
- package/lib/task-parser.ts +81 -0
- package/lib/task-utils.ts +52 -0
- package/lib/use-is-electron.ts +19 -0
- package/lib/use-is-mobile.ts +22 -0
- package/lib/validation/calendar-feed.ts +31 -0
- package/lib/validation/note.ts +27 -0
- package/lib/validation/task.ts +26 -0
- package/lib/view-preferences.test.ts +54 -0
- package/lib/view-preferences.ts +28 -0
- package/lib/workspace.ts +66 -0
- package/next.config.ts +21 -0
- package/package.json +49 -3
- package/postcss.config.mjs +7 -0
- package/prisma/migrations/20260519021916_init/migration.sql +388 -0
- package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
- package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
- package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
- package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
- package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
- package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
- package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
- package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
- package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
- package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
- package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
- package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
- package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
- package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
- package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
- package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
- package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +457 -0
- package/public/Logo_icon.svg +1 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +4 -0
- package/public/icon_dark.svg +1 -0
- package/public/knotpad_icon.svg +1 -0
- package/public/knotpad_logo_full.svg +1 -0
- package/public/manifest.json +14 -0
- package/public/next.svg +1 -0
- package/public/sw.js +137 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +35 -0
- package/brief.js +0 -311
package/lib/email.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transactional email service backed by Brevo (formerly Sendinblue).
|
|
3
|
+
*
|
|
4
|
+
* Identities are read from environment variables so they can be swapped
|
|
5
|
+
* without code changes:
|
|
6
|
+
*
|
|
7
|
+
* BREVO_API_KEY – shared API key
|
|
8
|
+
* EMAIL_AUTH_FROM – "Display Name <addr>" (auth emails)
|
|
9
|
+
* EMAIL_AUTH_NAME – sender display name
|
|
10
|
+
* EMAIL_AUTH_EMAIL – sender address
|
|
11
|
+
* EMAIL_NOTIFY_FROM – "Display Name <addr>" (notifications)
|
|
12
|
+
* EMAIL_NOTIFY_NAME – sender display name
|
|
13
|
+
* EMAIL_NOTIFY_EMAIL – sender address
|
|
14
|
+
*
|
|
15
|
+
* If a given identity is not configured the call is silently skipped (and a
|
|
16
|
+
* warning is logged) so local/dev environments without Brevo still work.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Types
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export type EmailIdentity = "auth" | "notify";
|
|
24
|
+
|
|
25
|
+
export interface SendEmailOptions {
|
|
26
|
+
/** Which sender identity to use. */
|
|
27
|
+
identity: EmailIdentity;
|
|
28
|
+
/** Recipient email address. */
|
|
29
|
+
to: string;
|
|
30
|
+
/** Email subject line. */
|
|
31
|
+
subject: string;
|
|
32
|
+
/** HTML body. */
|
|
33
|
+
html: string;
|
|
34
|
+
/** Optional plain-text body. */
|
|
35
|
+
text?: string;
|
|
36
|
+
/** Optional reply-to address. */
|
|
37
|
+
replyTo?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface BrevoSender {
|
|
41
|
+
name: string;
|
|
42
|
+
email: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Identity helpers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse a "Display Name <addr>" string into { name, email }.
|
|
51
|
+
* Falls back to the env-provided name/email pair if FROM isn't set.
|
|
52
|
+
*/
|
|
53
|
+
function parseFromHeader(
|
|
54
|
+
fromEnv: string | undefined,
|
|
55
|
+
nameEnv: string | undefined,
|
|
56
|
+
emailEnv: string | undefined
|
|
57
|
+
): BrevoSender | null {
|
|
58
|
+
if (fromEnv) {
|
|
59
|
+
const match = /^(.+?)\s*<([^>]+)>$/.exec(fromEnv);
|
|
60
|
+
if (match) return { name: match[1].trim(), email: match[2].trim() };
|
|
61
|
+
}
|
|
62
|
+
if (nameEnv && emailEnv) return { name: nameEnv, email: emailEnv };
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getSender(identity: EmailIdentity): BrevoSender | null {
|
|
67
|
+
if (identity === "auth") {
|
|
68
|
+
return parseFromHeader(
|
|
69
|
+
process.env.EMAIL_AUTH_FROM,
|
|
70
|
+
process.env.EMAIL_AUTH_NAME,
|
|
71
|
+
process.env.EMAIL_AUTH_EMAIL
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return parseFromHeader(
|
|
75
|
+
process.env.EMAIL_NOTIFY_FROM,
|
|
76
|
+
process.env.EMAIL_NOTIFY_NAME,
|
|
77
|
+
process.env.EMAIL_NOTIFY_EMAIL
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Public API
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Send a transactional email via Brevo. Returns `true` on success, `false`
|
|
87
|
+
* when the email could not be sent (missing config, API error, etc.).
|
|
88
|
+
*/
|
|
89
|
+
export async function sendEmail(opts: SendEmailOptions): Promise<boolean> {
|
|
90
|
+
const apiKey = process.env.BREVO_API_KEY;
|
|
91
|
+
if (!apiKey) {
|
|
92
|
+
console.warn("[email] BREVO_API_KEY not configured — skipping email send.");
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sender = getSender(opts.identity);
|
|
97
|
+
if (!sender) {
|
|
98
|
+
console.warn(
|
|
99
|
+
`[email] Sender identity "${opts.identity}" not configured — skipping email send.`
|
|
100
|
+
);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const body: Record<string, unknown> = {
|
|
105
|
+
sender,
|
|
106
|
+
to: [{ email: opts.to }],
|
|
107
|
+
subject: opts.subject,
|
|
108
|
+
htmlContent: opts.html,
|
|
109
|
+
};
|
|
110
|
+
if (opts.text) body.textContent = opts.text;
|
|
111
|
+
if (opts.replyTo) body.replyTo = { email: opts.replyTo };
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch("https://api.brevo.com/v3/smtp/email", {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
accept: "application/json",
|
|
119
|
+
"api-key": apiKey,
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify(body),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
const detail = await res.text().catch(() => "");
|
|
126
|
+
console.error(`[email] Brevo API ${res.status}: ${detail}`);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return true;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error("[email] Brevo send failed:", err);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Convenience: send an auth-related email (magic link, password reset, etc.).
|
|
139
|
+
*/
|
|
140
|
+
export function sendAuthEmail(
|
|
141
|
+
to: string,
|
|
142
|
+
subject: string,
|
|
143
|
+
html: string,
|
|
144
|
+
text?: string
|
|
145
|
+
) {
|
|
146
|
+
return sendEmail({ identity: "auth", to, subject, html, text });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Convenience: send a system notification email (report, update, etc.).
|
|
151
|
+
*/
|
|
152
|
+
export function sendNotifyEmail(
|
|
153
|
+
to: string,
|
|
154
|
+
subject: string,
|
|
155
|
+
html: string,
|
|
156
|
+
text?: string
|
|
157
|
+
) {
|
|
158
|
+
return sendEmail({ identity: "notify", to, subject, html, text });
|
|
159
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { encryptNote, decryptNote, isEncrypted, generateSalt, ENC_PREFIX } from "@/lib/encryption";
|
|
3
|
+
|
|
4
|
+
const SECRET = "test-pepper-do-not-use-in-prod";
|
|
5
|
+
|
|
6
|
+
describe("encryption", () => {
|
|
7
|
+
it("round-trips plaintext through encrypt/decrypt", async () => {
|
|
8
|
+
const salt = generateSalt();
|
|
9
|
+
const plain = "# Title\n\n- [ ] do thing @alice 2026-01-02";
|
|
10
|
+
const cipher = await encryptNote(plain, SECRET, salt);
|
|
11
|
+
expect(cipher.startsWith(ENC_PREFIX)).toBe(true);
|
|
12
|
+
expect(cipher).not.toContain("do thing");
|
|
13
|
+
expect(await decryptNote(cipher, SECRET, salt)).toBe(plain);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("marks ciphertext and not plaintext via isEncrypted", async () => {
|
|
17
|
+
const salt = generateSalt();
|
|
18
|
+
expect(isEncrypted("just some text")).toBe(false);
|
|
19
|
+
expect(isEncrypted(await encryptNote("hello", SECRET, salt))).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("is idempotent — never double-encrypts", async () => {
|
|
23
|
+
const salt = generateSalt();
|
|
24
|
+
const once = await encryptNote("hello", SECRET, salt);
|
|
25
|
+
const twice = await encryptNote(once, SECRET, salt);
|
|
26
|
+
expect(twice).toBe(once);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns legacy plaintext unchanged on decrypt", async () => {
|
|
30
|
+
const salt = generateSalt();
|
|
31
|
+
expect(await decryptNote("legacy plaintext", SECRET, salt)).toBe("legacy plaintext");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("produces different ciphertext for the same input (random IV)", async () => {
|
|
35
|
+
const salt = generateSalt();
|
|
36
|
+
const a = await encryptNote("hello", SECRET, salt);
|
|
37
|
+
const b = await encryptNote("hello", SECRET, salt);
|
|
38
|
+
expect(a).not.toBe(b);
|
|
39
|
+
expect(await decryptNote(b, SECRET, salt)).toBe("hello");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-256-GCM encryption-at-rest for Brief note content.
|
|
3
|
+
*
|
|
4
|
+
* Architecture (server-side, NOT end-to-end):
|
|
5
|
+
* - Server key: ENCRYPTION_PEPPER env var (never stored in the DB)
|
|
6
|
+
* - Workspace salt: random 16 bytes, stored in workspace.encryptionSalt (not sensitive)
|
|
7
|
+
* - Derived key: PBKDF2(pepper, salt, 100_000 iterations, SHA-256) → 256-bit AES key
|
|
8
|
+
* - Ciphertext: "enc:v1:" + base64( [IV (12 bytes)] + [GCM ciphertext+tag] )
|
|
9
|
+
*
|
|
10
|
+
* The server holds the key, so this protects against a database dump / at-rest
|
|
11
|
+
* exposure — it is deliberately NOT E2E (that was dropped to keep multi-device
|
|
12
|
+
* and team sync simple). Note content must never be persisted as plaintext.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const PBKDF2_ITERATIONS = 100_000;
|
|
16
|
+
const SALT_BYTES = 16;
|
|
17
|
+
const IV_BYTES = 12; // AES-GCM standard
|
|
18
|
+
|
|
19
|
+
// Version marker prepended to every ciphertext. Lets us detect encrypted vs
|
|
20
|
+
// plaintext content unambiguously (the old base64 heuristic mis-classified
|
|
21
|
+
// plaintext that happened to look base64-ish).
|
|
22
|
+
export const ENC_PREFIX = "enc:v1:";
|
|
23
|
+
|
|
24
|
+
export function generateSalt(): string {
|
|
25
|
+
const bytes = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
|
26
|
+
return Buffer.from(bytes).toString("base64");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- Key derivation ---
|
|
30
|
+
|
|
31
|
+
async function deriveKey(secret: string, saltBase64: string): Promise<CryptoKey> {
|
|
32
|
+
const enc = new TextEncoder();
|
|
33
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
34
|
+
"raw",
|
|
35
|
+
enc.encode(secret),
|
|
36
|
+
"PBKDF2",
|
|
37
|
+
false,
|
|
38
|
+
["deriveKey"]
|
|
39
|
+
);
|
|
40
|
+
const salt = Buffer.from(saltBase64, "base64");
|
|
41
|
+
return crypto.subtle.deriveKey(
|
|
42
|
+
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
|
|
43
|
+
keyMaterial,
|
|
44
|
+
{ name: "AES-GCM", length: 256 },
|
|
45
|
+
false,
|
|
46
|
+
["encrypt", "decrypt"]
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Encrypt / Decrypt ---
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Encrypt plaintext using the server key + workspace salt.
|
|
54
|
+
* Returns: ENC_PREFIX + base64([IV] + [ciphertext]).
|
|
55
|
+
* Returns already-encrypted input unchanged (idempotent).
|
|
56
|
+
*/
|
|
57
|
+
export async function encryptNote(plaintext: string, secret: string, saltBase64: string): Promise<string> {
|
|
58
|
+
if (isEncrypted(plaintext)) return plaintext;
|
|
59
|
+
const key = await deriveKey(secret, saltBase64);
|
|
60
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
61
|
+
const enc = new TextEncoder();
|
|
62
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
63
|
+
{ name: "AES-GCM", iv },
|
|
64
|
+
key,
|
|
65
|
+
enc.encode(plaintext)
|
|
66
|
+
);
|
|
67
|
+
const combined = new Uint8Array(IV_BYTES + ciphertext.byteLength);
|
|
68
|
+
combined.set(iv, 0);
|
|
69
|
+
combined.set(new Uint8Array(ciphertext), IV_BYTES);
|
|
70
|
+
return ENC_PREFIX + Buffer.from(combined).toString("base64");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decrypt a value produced by encryptNote. If the input is not encrypted
|
|
75
|
+
* (legacy plaintext row), it is returned unchanged.
|
|
76
|
+
*/
|
|
77
|
+
export async function decryptNote(encrypted: string, secret: string, saltBase64: string): Promise<string> {
|
|
78
|
+
if (!isEncrypted(encrypted)) return encrypted; // legacy / plaintext safety
|
|
79
|
+
try {
|
|
80
|
+
const key = await deriveKey(secret, saltBase64);
|
|
81
|
+
const combined = Buffer.from(encrypted.slice(ENC_PREFIX.length), "base64");
|
|
82
|
+
const iv = combined.subarray(0, IV_BYTES);
|
|
83
|
+
const ciphertext = combined.subarray(IV_BYTES);
|
|
84
|
+
const dec = new TextDecoder();
|
|
85
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
|
|
86
|
+
return dec.decode(plaintext);
|
|
87
|
+
} catch {
|
|
88
|
+
// Not genuinely our ciphertext (e.g. a note that literally starts with the
|
|
89
|
+
// marker, or a corrupted row). Return the raw value rather than throwing so
|
|
90
|
+
// a single bad row can't 500 the whole note read.
|
|
91
|
+
return encrypted;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True if a string is Brief-encrypted ciphertext (carries the version marker). */
|
|
96
|
+
export function isEncrypted(content: string): boolean {
|
|
97
|
+
return content.startsWith(ENC_PREFIX);
|
|
98
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { extractSnippet } from "./extract-snippet";
|
|
3
|
+
|
|
4
|
+
describe("extractSnippet", () => {
|
|
5
|
+
it("extracts snippet from a checkbox task line", () => {
|
|
6
|
+
const content = [
|
|
7
|
+
"# Meeting notes",
|
|
8
|
+
"- [ ] ship docs @alice",
|
|
9
|
+
"- [ ] review PR",
|
|
10
|
+
"Some follow-up text",
|
|
11
|
+
].join("\n");
|
|
12
|
+
|
|
13
|
+
const result = extractSnippet(content, "ship docs");
|
|
14
|
+
expect(result).toContain("- [ ] ship docs @alice");
|
|
15
|
+
expect(result).toContain("# Meeting notes");
|
|
16
|
+
expect(result).toContain("- [ ] review PR");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("extracts snippet from a ((task title)) wikilink in plain text", () => {
|
|
20
|
+
const content = [
|
|
21
|
+
"Plan for the week:",
|
|
22
|
+
"We need to ((ship docs)) before Friday.",
|
|
23
|
+
"Then we can relax.",
|
|
24
|
+
].join("\n");
|
|
25
|
+
|
|
26
|
+
const result = extractSnippet(content, "ship docs");
|
|
27
|
+
expect(result).toContain("We need to ship docs before Friday.");
|
|
28
|
+
expect(result).not.toContain("((ship docs))");
|
|
29
|
+
expect(result).toContain("Plan for the week:");
|
|
30
|
+
expect(result).toContain("Then we can relax.");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("extracts snippet from a ((task title)) wikilink in a bullet list", () => {
|
|
34
|
+
const content = [
|
|
35
|
+
"- First item",
|
|
36
|
+
"- Check ((ship docs)) status",
|
|
37
|
+
"- Last item",
|
|
38
|
+
].join("\n");
|
|
39
|
+
|
|
40
|
+
const result = extractSnippet(content, "ship docs");
|
|
41
|
+
expect(result).toContain("- Check ship docs status");
|
|
42
|
+
expect(result).not.toContain("((ship docs))");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("finds multiple references in one note", () => {
|
|
46
|
+
const content = [
|
|
47
|
+
"Line one",
|
|
48
|
+
"((ship docs))",
|
|
49
|
+
"Line three",
|
|
50
|
+
"Line four",
|
|
51
|
+
"Line five",
|
|
52
|
+
"((ship docs)) again",
|
|
53
|
+
"Line seven",
|
|
54
|
+
].join("\n");
|
|
55
|
+
|
|
56
|
+
const result = extractSnippet(content, "ship docs");
|
|
57
|
+
const blocks = result.split("\n---\n");
|
|
58
|
+
expect(blocks).toHaveLength(2);
|
|
59
|
+
expect(blocks[0]).toContain("ship docs");
|
|
60
|
+
expect(blocks[1]).toContain("ship docs");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns empty string when there is no match", () => {
|
|
64
|
+
const content = "Just some random text.\nNothing here.";
|
|
65
|
+
expect(extractSnippet(content, "nonexistent task")).toBe("");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("does not duplicate when a checkbox line also contains a wikilink", () => {
|
|
69
|
+
const content = [
|
|
70
|
+
"- [ ] ((ship docs)) @alice",
|
|
71
|
+
"Next line",
|
|
72
|
+
].join("\n");
|
|
73
|
+
|
|
74
|
+
const result = extractSnippet(content, "ship docs");
|
|
75
|
+
const blocks = result.split("\n---\n").filter(Boolean);
|
|
76
|
+
expect(blocks).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("strips wikilink syntax even when it spans multiple lines in a block", () => {
|
|
80
|
+
const content = [
|
|
81
|
+
"Context line",
|
|
82
|
+
"See ((ship docs)) for details",
|
|
83
|
+
"More context",
|
|
84
|
+
].join("\n");
|
|
85
|
+
|
|
86
|
+
const result = extractSnippet(content, "ship docs");
|
|
87
|
+
expect(result).not.toContain("((ship docs))");
|
|
88
|
+
expect(result).toContain("See ship docs for details");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("finds checkbox lines that contain [[note refs]] and dates", () => {
|
|
92
|
+
const content = [
|
|
93
|
+
"# Meeting notes",
|
|
94
|
+
"- [ ] review [[Spec]] ((Task)) @bob <file.md> 2026-06-01..2026-06-05",
|
|
95
|
+
"- [ ] something else",
|
|
96
|
+
].join("\n");
|
|
97
|
+
|
|
98
|
+
const result = extractSnippet(content, "review");
|
|
99
|
+
expect(result).toContain(
|
|
100
|
+
"- [ ] review [[Spec]] ((Task)) @bob <file.md> 2026-06-01..2026-06-05"
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("finds checkbox lines that contain a single date", () => {
|
|
105
|
+
const content = [
|
|
106
|
+
"- [ ] ship docs 2026-05-20",
|
|
107
|
+
"- [ ] other task",
|
|
108
|
+
].join("\n");
|
|
109
|
+
|
|
110
|
+
const result = extractSnippet(content, "ship docs");
|
|
111
|
+
expect(result).toContain("- [ ] ship docs 2026-05-20");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does not collapse whitespace when matching checkbox titles", () => {
|
|
115
|
+
const content = [
|
|
116
|
+
"- [ ] ship docs",
|
|
117
|
+
"- [ ] other task",
|
|
118
|
+
].join("\n");
|
|
119
|
+
|
|
120
|
+
const result = extractSnippet(content, "ship docs");
|
|
121
|
+
expect(result).toContain("- [ ] ship docs");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function escapeRegExp(str: string): string {
|
|
2
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
const MENTION_RE = /@[\w-]+/g;
|
|
6
|
+
const FILEREF_RE = /<[^>]+>/g;
|
|
7
|
+
const NOTE_REF_RE = /\[\[[^\]]*\]\]/g;
|
|
8
|
+
const TASK_REF_RE = /\(\([^)]*\)\)/g;
|
|
9
|
+
const STATUS_COMMENT_RE = /\s*<!--task::[A-Z_]+-->/g;
|
|
10
|
+
const DATE_RANGE_RE = /\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}/g;
|
|
11
|
+
const DATE_RE = /\d{4}-\d{2}-\d{2}/g;
|
|
12
|
+
|
|
13
|
+
function cleanTaskTitle(text: string): string {
|
|
14
|
+
return text
|
|
15
|
+
.replace(NOTE_REF_RE, "")
|
|
16
|
+
.replace(TASK_REF_RE, "")
|
|
17
|
+
.replace(MENTION_RE, "")
|
|
18
|
+
.replace(FILEREF_RE, "")
|
|
19
|
+
.replace(DATE_RANGE_RE, "")
|
|
20
|
+
.replace(DATE_RE, "")
|
|
21
|
+
.replace(STATUS_COMMENT_RE, "")
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function extractSnippet(content: string, taskTitle: string): string {
|
|
26
|
+
const lines = content.split("\n");
|
|
27
|
+
const usedIndices = new Set<number>();
|
|
28
|
+
const snippets: string[] = [];
|
|
29
|
+
const wikilinkPattern = `\\(\\(\\s*${escapeRegExp(taskTitle)}\\s*\\)\\)`;
|
|
30
|
+
const wikilinkRe = new RegExp(wikilinkPattern);
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < lines.length; i++) {
|
|
33
|
+
if (usedIndices.has(i)) continue;
|
|
34
|
+
|
|
35
|
+
let isMatch = false;
|
|
36
|
+
let isWikilink = false;
|
|
37
|
+
|
|
38
|
+
// Pattern 1: checkbox task line
|
|
39
|
+
const checkboxPrefix = lines[i].match(/^(\s*)-\s+\[([ x])\]\s*/);
|
|
40
|
+
if (checkboxPrefix) {
|
|
41
|
+
const textAfterCheckbox = lines[i].slice(checkboxPrefix[0].length);
|
|
42
|
+
if (cleanTaskTitle(textAfterCheckbox) === taskTitle) {
|
|
43
|
+
isMatch = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Pattern 2: wikilink reference ((task title))
|
|
48
|
+
if (!isMatch && lines[i].match(wikilinkRe)) {
|
|
49
|
+
isMatch = true;
|
|
50
|
+
isWikilink = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!isMatch) continue;
|
|
54
|
+
usedIndices.add(i);
|
|
55
|
+
|
|
56
|
+
const start = Math.max(0, i - 2);
|
|
57
|
+
const end = Math.min(lines.length, i + 3);
|
|
58
|
+
let block = lines.slice(start, end).join("\n");
|
|
59
|
+
|
|
60
|
+
if (isWikilink) {
|
|
61
|
+
// Strip ((...)) syntax so the snippet reads as natural text
|
|
62
|
+
block = block.replace(new RegExp(wikilinkPattern, "g"), taskTitle);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
snippets.push(block);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return snippets.join("\n---\n");
|
|
69
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { prisma } from "@/lib/prisma";
|
|
2
|
+
|
|
3
|
+
export type KanbanStatusConfig = {
|
|
4
|
+
key: string;
|
|
5
|
+
label: string;
|
|
6
|
+
color: string;
|
|
7
|
+
order: number;
|
|
8
|
+
isVisible: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const DEFAULT_STATUSES: KanbanStatusConfig[] = [
|
|
12
|
+
{ key: "OPEN", label: "Open", color: "border-zinc-700", order: 0, isVisible: true },
|
|
13
|
+
{ key: "CLAIMED", label: "Claimed", color: "border-violet-700", order: 1, isVisible: true },
|
|
14
|
+
{ key: "IN_PROGRESS", label: "In Progress", color: "border-blue-700", order: 2, isVisible: true },
|
|
15
|
+
{ key: "REVIEW", label: "Review", color: "border-amber-700", order: 3, isVisible: true },
|
|
16
|
+
{ key: "DONE", label: "Done", color: "border-emerald-700", order: 4, isVisible: true },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export async function getKanbanStatuses(workspaceId: string): Promise<KanbanStatusConfig[]> {
|
|
20
|
+
const rows = await prisma.kanbanStatus.findMany({
|
|
21
|
+
where: { workspaceId },
|
|
22
|
+
orderBy: { order: "asc" },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (rows.length === 0) {
|
|
26
|
+
return DEFAULT_STATUSES;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return rows.map((r) => ({
|
|
30
|
+
key: r.key,
|
|
31
|
+
label: r.label,
|
|
32
|
+
color: r.color,
|
|
33
|
+
order: r.order,
|
|
34
|
+
isVisible: r.isVisible,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function seedDefaultKanbanStatuses(
|
|
39
|
+
workspaceId: string,
|
|
40
|
+
tx?: Parameters<Parameters<typeof prisma.$transaction>[0]>[0]
|
|
41
|
+
) {
|
|
42
|
+
const client = tx ?? prisma;
|
|
43
|
+
const existing = await client.kanbanStatus.findFirst({
|
|
44
|
+
where: { workspaceId },
|
|
45
|
+
});
|
|
46
|
+
if (existing) return;
|
|
47
|
+
|
|
48
|
+
await client.kanbanStatus.createMany({
|
|
49
|
+
data: DEFAULT_STATUSES.map((s) => ({ ...s, workspaceId })),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getDefaultKanbanStatuses(): KanbanStatusConfig[] {
|
|
54
|
+
return DEFAULT_STATUSES.map((s) => ({ ...s }));
|
|
55
|
+
}
|
package/lib/license.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Derive Pro / Cloud entitlement from the canonical license fields rather than
|
|
2
|
+
// the denormalized `isPro` / `isCloud` boolean columns. Those booleans are a
|
|
3
|
+
// cache kept in sync by the admin license route and the Stripe webhook, but they
|
|
4
|
+
// drift when `planType` / `licenseType` are edited directly in the DB. The gating
|
|
5
|
+
// paths should treat this derivation as authoritative.
|
|
6
|
+
//
|
|
7
|
+
// Entitlement rule (mirrors prisma/schema.prisma:73-74):
|
|
8
|
+
// isPro / isCloud === planType != FREE || licenseType == COMPLIMENTARY
|
|
9
|
+
|
|
10
|
+
type LicenseFields = {
|
|
11
|
+
planType: string;
|
|
12
|
+
licenseType: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function isWorkspacePro(ws: LicenseFields): boolean {
|
|
16
|
+
return ws.planType !== "FREE" || ws.licenseType === "COMPLIMENTARY";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Today Cloud access has the same derivation as Pro. Kept as a separate export so
|
|
20
|
+
// call sites read clearly and the two can diverge later without a refactor.
|
|
21
|
+
export const isWorkspaceCloud = isWorkspacePro;
|
package/lib/limits.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Shared guard rails for note size. Kept in lib (not a route) so both route
|
|
2
|
+
// handlers and zod validation schemas can import them without a circular dep.
|
|
3
|
+
export const MAX_CONTENT_LEN = 1_000_000; // ~1 MB of markdown
|
|
4
|
+
export const MAX_TITLE_LEN = 500;
|
|
5
|
+
|
|
6
|
+
// Calendar feed (read-only .ics import) limits.
|
|
7
|
+
export const MAX_CALENDAR_FEEDS = 5;
|
|
8
|
+
export const MAX_FEED_LABEL_LEN = 60;
|
|
9
|
+
export const ICS_FETCH_TIMEOUT_MS = 10_000;
|
|
10
|
+
export const ICS_MAX_BYTES = 2_000_000; // 2 MB
|
|
11
|
+
export const CALENDAR_FEED_PAST_DAYS = 30;
|
|
12
|
+
export const CALENDAR_FEED_FUTURE_DAYS = 180;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Predefined palette for calendar feed colour chips. Keys are stored in the DB;
|
|
16
|
+
* values map to Tailwind bg-* classes used in the calendar view.
|
|
17
|
+
*/
|
|
18
|
+
export const FEED_COLORS = {
|
|
19
|
+
sky: { bg: "bg-sky-600", bgLight: "bg-sky-600/20", border: "border-sky-500/50", text: "text-sky-300", label: "Sky" },
|
|
20
|
+
violet: { bg: "bg-violet-600", bgLight: "bg-violet-600/20", border: "border-violet-500/50", text: "text-violet-300", label: "Violet" },
|
|
21
|
+
emerald: { bg: "bg-emerald-600", bgLight: "bg-emerald-600/20", border: "border-emerald-500/50", text: "text-emerald-300", label: "Emerald" },
|
|
22
|
+
amber: { bg: "bg-amber-600", bgLight: "bg-amber-600/20", border: "border-amber-500/50", text: "text-amber-300", label: "Amber" },
|
|
23
|
+
rose: { bg: "bg-rose-600", bgLight: "bg-rose-600/20", border: "border-rose-500/50", text: "text-rose-300", label: "Rose" },
|
|
24
|
+
cyan: { bg: "bg-cyan-600", bgLight: "bg-cyan-600/20", border: "border-cyan-500/50", text: "text-cyan-300", label: "Cyan" },
|
|
25
|
+
fuchsia: { bg: "bg-fuchsia-600", bgLight: "bg-fuchsia-600/20", border: "border-fuchsia-500/50", text: "text-fuchsia-300", label: "Fuchsia" },
|
|
26
|
+
orange: { bg: "bg-orange-600", bgLight: "bg-orange-600/20", border: "border-orange-500/50", text: "text-orange-300", label: "Orange" },
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export type FeedColorKey = keyof typeof FEED_COLORS;
|
|
30
|
+
export const FEED_COLOR_KEYS = Object.keys(FEED_COLORS) as FeedColorKey[];
|
|
31
|
+
export const DEFAULT_FEED_COLOR: FeedColorKey = "sky";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const findUnique = vi.fn();
|
|
4
|
+
|
|
5
|
+
vi.mock("@/lib/prisma", () => ({
|
|
6
|
+
prisma: {
|
|
7
|
+
mcpToken: {
|
|
8
|
+
findUnique,
|
|
9
|
+
update: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("jose", () => ({
|
|
15
|
+
jwtVerify: vi.fn().mockResolvedValue({
|
|
16
|
+
payload: { userId: "user-1", workspaceId: "ws-1", tokenId: "tok-1" },
|
|
17
|
+
}),
|
|
18
|
+
SignJWT: class {},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
describe("verifyMcpToken", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
findUnique.mockReset();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("rejects a token if the workspace does not match the JWT payload", async () => {
|
|
27
|
+
findUnique.mockResolvedValue({
|
|
28
|
+
id: "tok-1",
|
|
29
|
+
userId: "user-1",
|
|
30
|
+
workspaceId: "ws-2",
|
|
31
|
+
revokedAt: null,
|
|
32
|
+
alias: "Laptop",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const { verifyMcpToken } = await import("@/lib/mcp-auth");
|
|
36
|
+
|
|
37
|
+
await expect(verifyMcpToken("secret")).resolves.toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("accepts a token when the workspace matches the JWT payload", async () => {
|
|
41
|
+
findUnique.mockResolvedValue({
|
|
42
|
+
id: "tok-1",
|
|
43
|
+
userId: "user-1",
|
|
44
|
+
workspaceId: "ws-1",
|
|
45
|
+
revokedAt: null,
|
|
46
|
+
alias: "Laptop",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const { verifyMcpToken } = await import("@/lib/mcp-auth");
|
|
50
|
+
|
|
51
|
+
await expect(verifyMcpToken("secret")).resolves.toEqual({
|
|
52
|
+
userId: "user-1",
|
|
53
|
+
workspaceId: "ws-1",
|
|
54
|
+
tokenId: "tok-1",
|
|
55
|
+
alias: "Laptop",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|