@knotpad/app 0.1.0

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.
Files changed (342) hide show
  1. package/README.md +167 -0
  2. package/app/(app)/calendar/page.tsx +57 -0
  3. package/app/(app)/error.tsx +35 -0
  4. package/app/(app)/graph/page.tsx +32 -0
  5. package/app/(app)/guide/page.tsx +21 -0
  6. package/app/(app)/kanban/loading.tsx +24 -0
  7. package/app/(app)/kanban/page.tsx +59 -0
  8. package/app/(app)/layout.tsx +122 -0
  9. package/app/(app)/list/loading.tsx +21 -0
  10. package/app/(app)/list/page.tsx +137 -0
  11. package/app/(app)/loading.tsx +18 -0
  12. package/app/(app)/notes/[noteId]/page.tsx +84 -0
  13. package/app/(app)/notes/layout.tsx +30 -0
  14. package/app/(app)/notes/page.tsx +39 -0
  15. package/app/(app)/page.tsx +5 -0
  16. package/app/(app)/settings/agent-token/page.tsx +59 -0
  17. package/app/(app)/settings/backup/page.tsx +49 -0
  18. package/app/(app)/settings/billing/page.tsx +53 -0
  19. package/app/(app)/settings/calendar/page.tsx +41 -0
  20. package/app/(app)/settings/layout.test.tsx +39 -0
  21. package/app/(app)/settings/layout.tsx +71 -0
  22. package/app/(app)/settings/page.tsx +4 -0
  23. package/app/(app)/settings/security/page.tsx +43 -0
  24. package/app/(app)/settings/team/page.tsx +74 -0
  25. package/app/(app)/settings/workspace/page.tsx +27 -0
  26. package/app/(app)/tasks/[taskId]/page.tsx +79 -0
  27. package/app/(auth)/forgot-password/page.tsx +106 -0
  28. package/app/(auth)/guest/page.tsx +56 -0
  29. package/app/(auth)/layout.tsx +13 -0
  30. package/app/(auth)/login/page.tsx +14 -0
  31. package/app/(auth)/register/page.tsx +193 -0
  32. package/app/(auth)/reset-password/page.tsx +138 -0
  33. package/app/api/account/claim/route.tsx +135 -0
  34. package/app/api/admin/backfill-encryption/route.tsx +43 -0
  35. package/app/api/admin/license/route.tsx +42 -0
  36. package/app/api/auth/2fa/route.tsx +148 -0
  37. package/app/api/auth/[...nextauth]/route.tsx +3 -0
  38. package/app/api/auth/change-password/route.tsx +61 -0
  39. package/app/api/auth/check-2fa/route.tsx +19 -0
  40. package/app/api/auth/forgot-password/route.tsx +65 -0
  41. package/app/api/auth/reset-password/route.tsx +52 -0
  42. package/app/api/auth/verify-2fa/route.tsx +88 -0
  43. package/app/api/backup/download/db/route.ts +29 -0
  44. package/app/api/backup/download/notes/route.ts +25 -0
  45. package/app/api/backup/settings/route.ts +92 -0
  46. package/app/api/billing/checkout/route.tsx +81 -0
  47. package/app/api/billing/migrate/route.tsx +163 -0
  48. package/app/api/billing/portal/route.tsx +24 -0
  49. package/app/api/billing/setup-intent/route.tsx +55 -0
  50. package/app/api/billing/status/route.tsx +36 -0
  51. package/app/api/billing/subscribe/route.tsx +85 -0
  52. package/app/api/billing/webhook/route.tsx +199 -0
  53. package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
  54. package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
  55. package/app/api/calendar-feeds/events/route.tsx +82 -0
  56. package/app/api/calendar-feeds/route.tsx +52 -0
  57. package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
  58. package/app/api/cron/calendar-feeds/route.tsx +31 -0
  59. package/app/api/cron/stale-tasks/route.tsx +51 -0
  60. package/app/api/cron/sync/route.tsx +34 -0
  61. package/app/api/devices/[deviceId]/route.tsx +25 -0
  62. package/app/api/devices/route.tsx +41 -0
  63. package/app/api/export/route.tsx +40 -0
  64. package/app/api/feedback/route.tsx +54 -0
  65. package/app/api/folders/[folderId]/route.tsx +51 -0
  66. package/app/api/folders/route.tsx +37 -0
  67. package/app/api/graph/route.tsx +242 -0
  68. package/app/api/guest/route.tsx +58 -0
  69. package/app/api/health/route.tsx +10 -0
  70. package/app/api/holidays/countries/route.tsx +14 -0
  71. package/app/api/holidays/route.tsx +49 -0
  72. package/app/api/holidays/states/route.tsx +21 -0
  73. package/app/api/invites/[token]/route.tsx +131 -0
  74. package/app/api/invites/route.tsx +74 -0
  75. package/app/api/mcp/generate-token/route.tsx +55 -0
  76. package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
  77. package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
  78. package/app/api/notes/[noteId]/export/route.tsx +45 -0
  79. package/app/api/notes/[noteId]/route.tsx +360 -0
  80. package/app/api/notes/route.tsx +112 -0
  81. package/app/api/notifications/route.tsx +44 -0
  82. package/app/api/register/route.tsx +67 -0
  83. package/app/api/restore/route.tsx +148 -0
  84. package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
  85. package/app/api/sync/conflicts/route.tsx +48 -0
  86. package/app/api/sync/status/route.tsx +49 -0
  87. package/app/api/sync/trigger/route.tsx +15 -0
  88. package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
  89. package/app/api/tasks/[taskId]/route.tsx +259 -0
  90. package/app/api/tasks/bulk/route.tsx +133 -0
  91. package/app/api/tasks/route.tsx +36 -0
  92. package/app/api/workspace/active/route.tsx +39 -0
  93. package/app/api/workspace/create-team/route.tsx +42 -0
  94. package/app/api/workspace/kanban-statuses/route.tsx +71 -0
  95. package/app/api/workspace/members/[memberId]/route.tsx +69 -0
  96. package/app/api/workspace/route.tsx +24 -0
  97. package/app/download/page.tsx +170 -0
  98. package/app/favicon.ico +0 -0
  99. package/app/generated/prisma/client.d.ts +1 -0
  100. package/app/generated/prisma/client.js +5 -0
  101. package/app/generated/prisma/default.d.ts +1 -0
  102. package/app/generated/prisma/default.js +5 -0
  103. package/app/generated/prisma/edge.d.ts +1 -0
  104. package/app/generated/prisma/edge.js +497 -0
  105. package/app/generated/prisma/index-browser.js +523 -0
  106. package/app/generated/prisma/index.d.ts +46376 -0
  107. package/app/generated/prisma/index.js +497 -0
  108. package/app/generated/prisma/package.json +144 -0
  109. package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
  110. package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
  111. package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
  112. package/app/generated/prisma/runtime/client.d.ts +3386 -0
  113. package/app/generated/prisma/runtime/client.js +86 -0
  114. package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
  115. package/app/generated/prisma/runtime/index-browser.js +6 -0
  116. package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
  117. package/app/generated/prisma/schema.prisma +456 -0
  118. package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
  119. package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
  120. package/app/globals.css +54 -0
  121. package/app/invite/[token]/page.tsx +52 -0
  122. package/app/layout.tsx +90 -0
  123. package/app/mcp/route.tsx +430 -0
  124. package/app/opengraph-image.tsx +120 -0
  125. package/app/page.tsx +398 -0
  126. package/app/privacy/page.tsx +69 -0
  127. package/app/robots.tsx +25 -0
  128. package/app/sitemap.tsx +36 -0
  129. package/app/terms/page.tsx +69 -0
  130. package/app/upgrade/page.tsx +75 -0
  131. package/auth.config.ts +33 -0
  132. package/auth.ts +79 -0
  133. package/bin/brief.js +224 -0
  134. package/components/auth/login-form.tsx +302 -0
  135. package/components/auth/password-checklist.tsx +31 -0
  136. package/components/auth/password-input.tsx +36 -0
  137. package/components/auth/switch-account-button.test.tsx +22 -0
  138. package/components/auth/switch-account-button.tsx +19 -0
  139. package/components/auth/two-factor-input.tsx +116 -0
  140. package/components/billing/billing-dashboard.tsx +265 -0
  141. package/components/billing/card-form.tsx +210 -0
  142. package/components/billing/claim-account-form.tsx +99 -0
  143. package/components/branding/app-logo.test.tsx +20 -0
  144. package/components/branding/app-logo.tsx +25 -0
  145. package/components/calendar/calendar-agenda.tsx +150 -0
  146. package/components/calendar/calendar-drag.test.tsx +177 -0
  147. package/components/calendar/calendar-grid.tsx +357 -0
  148. package/components/calendar/calendar-hooks.test.tsx +27 -0
  149. package/components/calendar/calendar-hooks.ts +351 -0
  150. package/components/calendar/calendar-toolbar.test.tsx +68 -0
  151. package/components/calendar/calendar-toolbar.tsx +291 -0
  152. package/components/calendar/calendar-types.ts +148 -0
  153. package/components/calendar/calendar-view.test.tsx +295 -0
  154. package/components/calendar/calendar-view.tsx +307 -0
  155. package/components/calendar/day-detail-popover.tsx +174 -0
  156. package/components/calendar/task-chip.tsx +86 -0
  157. package/components/command/command-palette.test.tsx +33 -0
  158. package/components/command/command-palette.tsx +310 -0
  159. package/components/download-cta.tsx +87 -0
  160. package/components/feedback/feedback-popup.tsx +207 -0
  161. package/components/graph/graph-draw.ts +337 -0
  162. package/components/graph/graph-overlays.tsx +160 -0
  163. package/components/graph/graph-page.test.tsx +131 -0
  164. package/components/graph/graph-page.tsx +263 -0
  165. package/components/graph/graph-types.ts +47 -0
  166. package/components/graph/graph-view.tsx +322 -0
  167. package/components/guide/guide-view.tsx +522 -0
  168. package/components/kanban/kanban-board.test.tsx +128 -0
  169. package/components/kanban/kanban-board.tsx +361 -0
  170. package/components/kanban/kanban-card-menu.tsx +102 -0
  171. package/components/kanban/kanban-card.tsx +227 -0
  172. package/components/kanban/kanban-column.tsx +49 -0
  173. package/components/kanban/kanban-status-context.tsx +28 -0
  174. package/components/landing/calendar-sandbox.test.tsx +15 -0
  175. package/components/landing/calendar-sandbox.tsx +107 -0
  176. package/components/landing/graph-sandbox.test.tsx +27 -0
  177. package/components/landing/graph-sandbox.tsx +80 -0
  178. package/components/landing/kanban-sandbox.test.tsx +24 -0
  179. package/components/landing/kanban-sandbox.tsx +101 -0
  180. package/components/landing/landing-showcase.test.tsx +21 -0
  181. package/components/landing/landing-showcase.tsx +54 -0
  182. package/components/landing/list-sandbox.tsx +86 -0
  183. package/components/landing/mock-workspace.ts +168 -0
  184. package/components/landing/notes-sandbox.test.tsx +14 -0
  185. package/components/landing/notes-sandbox.tsx +88 -0
  186. package/components/layout/app-shell.tsx +83 -0
  187. package/components/layout/backup-scheduler.tsx +122 -0
  188. package/components/layout/bottom-nav.tsx +43 -0
  189. package/components/layout/icon-bar.test.tsx +29 -0
  190. package/components/layout/icon-bar.tsx +118 -0
  191. package/components/layout/mobile-top-bar.tsx +68 -0
  192. package/components/layout/notes-panel-folder.tsx +127 -0
  193. package/components/layout/notes-panel-note-item.tsx +140 -0
  194. package/components/layout/notes-panel-task-tab.tsx +63 -0
  195. package/components/layout/notes-panel-types.ts +44 -0
  196. package/components/layout/notes-panel.tsx +476 -0
  197. package/components/layout/notification-bell.tsx +251 -0
  198. package/components/layout/paywall-screen.tsx +41 -0
  199. package/components/layout/pro-banner.tsx +76 -0
  200. package/components/layout/sw-register.tsx +27 -0
  201. package/components/layout/workspace-switcher.tsx +90 -0
  202. package/components/notes/mobile-bottom-sheet.tsx +99 -0
  203. package/components/notes/note-editor-context-menu.tsx +47 -0
  204. package/components/notes/note-editor-dom.ts +33 -0
  205. package/components/notes/note-editor-dropdowns.tsx +484 -0
  206. package/components/notes/note-editor-hooks.ts +692 -0
  207. package/components/notes/note-editor-keyboard.ts +305 -0
  208. package/components/notes/note-editor-overlay.tsx +90 -0
  209. package/components/notes/note-editor.test.tsx +372 -0
  210. package/components/notes/note-editor.tsx +662 -0
  211. package/components/notes/note-preview-pane.tsx +156 -0
  212. package/components/notes/note-tabs.tsx +120 -0
  213. package/components/notes/note-types.tsx +157 -0
  214. package/components/settings/accept-invite.tsx +108 -0
  215. package/components/settings/agent-token-settings.tsx +369 -0
  216. package/components/settings/backup-restore-settings.test.tsx +25 -0
  217. package/components/settings/backup-restore-settings.tsx +327 -0
  218. package/components/settings/calendar-feeds-settings.tsx +489 -0
  219. package/components/settings/calendar-general-settings.tsx +174 -0
  220. package/components/settings/confirm-danger-action.test.tsx +215 -0
  221. package/components/settings/confirm-danger-action.tsx +65 -0
  222. package/components/settings/security-settings.tsx +252 -0
  223. package/components/settings/settings-guidance.test.tsx +98 -0
  224. package/components/settings/team-settings.tsx +319 -0
  225. package/components/settings/two-factor-auth.tsx +296 -0
  226. package/components/settings/workspace-settings-client.tsx +363 -0
  227. package/components/settings/workspace-settings-form.tsx +73 -0
  228. package/components/sync/conflict-viewer.tsx +247 -0
  229. package/components/sync/sync-indicator.tsx +171 -0
  230. package/components/tasks/snippet-thread.tsx +119 -0
  231. package/components/tasks/status-dot.tsx +47 -0
  232. package/components/tasks/task-badge.tsx +43 -0
  233. package/components/tasks/task-detail.test.tsx +187 -0
  234. package/components/tasks/task-detail.tsx +458 -0
  235. package/components/tasks/task-list-filters.test.tsx +75 -0
  236. package/components/tasks/task-list-filters.tsx +163 -0
  237. package/components/tasks/task-list-types.ts +20 -0
  238. package/components/tasks/task-list.test.tsx +175 -0
  239. package/components/tasks/task-list.tsx +481 -0
  240. package/components/tasks/task-row.tsx +85 -0
  241. package/components/tasks/task-table-row.tsx +259 -0
  242. package/components/ui/skeleton.tsx +3 -0
  243. package/components/ui/toast.test.tsx +42 -0
  244. package/components/ui/toast.tsx +70 -0
  245. package/electron/main.ts +251 -0
  246. package/electron/preload.ts +56 -0
  247. package/instrumentation.tsx +23 -0
  248. package/lib/api-error.ts +50 -0
  249. package/lib/backup/backup-runner.test.ts +32 -0
  250. package/lib/backup/backup-runner.ts +19 -0
  251. package/lib/backup/backup-schedule.test.ts +23 -0
  252. package/lib/backup/backup-schedule.ts +55 -0
  253. package/lib/backup/backup-settings.test.ts +30 -0
  254. package/lib/backup/backup-settings.ts +27 -0
  255. package/lib/backup/export-notes-zip.test.ts +26 -0
  256. package/lib/backup/export-notes-zip.ts +82 -0
  257. package/lib/backup/export-workspace-backup.test.ts +17 -0
  258. package/lib/backup/export-workspace-backup.ts +77 -0
  259. package/lib/backup/restore-workspace-from-export.test.ts +18 -0
  260. package/lib/backup/restore-workspace-from-export.ts +183 -0
  261. package/lib/backup/types.ts +14 -0
  262. package/lib/brand-icons.ts +1 -0
  263. package/lib/calendar-feed-crypto.ts +38 -0
  264. package/lib/calendar-feed.ts +239 -0
  265. package/lib/client/online-status.ts +47 -0
  266. package/lib/conflict-resolver.test.ts +57 -0
  267. package/lib/conflict-resolver.ts +240 -0
  268. package/lib/db-init.ts +79 -0
  269. package/lib/email.ts +159 -0
  270. package/lib/encryption.test.ts +41 -0
  271. package/lib/encryption.ts +98 -0
  272. package/lib/extract-snippet.test.ts +123 -0
  273. package/lib/extract-snippet.ts +69 -0
  274. package/lib/kanban-status.ts +55 -0
  275. package/lib/license.ts +21 -0
  276. package/lib/limits.ts +31 -0
  277. package/lib/mcp-auth.test.ts +58 -0
  278. package/lib/mcp-auth.ts +65 -0
  279. package/lib/mcp-contract.test.ts +25 -0
  280. package/lib/mcp-contract.ts +210 -0
  281. package/lib/mcp-handler.ts +31 -0
  282. package/lib/mcp-url.test.ts +12 -0
  283. package/lib/mcp-url.ts +7 -0
  284. package/lib/mentions.test.ts +45 -0
  285. package/lib/mentions.ts +73 -0
  286. package/lib/note-crypto.ts +108 -0
  287. package/lib/note-sync.ts +201 -0
  288. package/lib/note-title.ts +93 -0
  289. package/lib/prisma.ts +193 -0
  290. package/lib/pro-flush.ts +292 -0
  291. package/lib/rate-limit.ts +57 -0
  292. package/lib/stripe.ts +38 -0
  293. package/lib/sync-worker.ts +388 -0
  294. package/lib/task-parser.test.ts +91 -0
  295. package/lib/task-parser.ts +81 -0
  296. package/lib/task-utils.ts +52 -0
  297. package/lib/use-is-electron.ts +19 -0
  298. package/lib/use-is-mobile.ts +22 -0
  299. package/lib/validation/calendar-feed.ts +31 -0
  300. package/lib/validation/note.ts +27 -0
  301. package/lib/validation/task.ts +26 -0
  302. package/lib/view-preferences.test.ts +54 -0
  303. package/lib/view-preferences.ts +28 -0
  304. package/lib/workspace.ts +66 -0
  305. package/next.config.ts +21 -0
  306. package/package.json +99 -0
  307. package/postcss.config.mjs +7 -0
  308. package/prisma/migrations/20260519021916_init/migration.sql +388 -0
  309. package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
  310. package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
  311. package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
  312. package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
  313. package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
  314. package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
  315. package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
  316. package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
  317. package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
  318. package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
  319. package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
  320. package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
  321. package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
  322. package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
  323. package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
  324. package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
  325. package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
  326. package/prisma/migrations/migration_lock.toml +3 -0
  327. package/prisma/schema.prisma +457 -0
  328. package/public/Logo_icon.svg +1 -0
  329. package/public/file.svg +1 -0
  330. package/public/globe.svg +1 -0
  331. package/public/icon-192.png +0 -0
  332. package/public/icon-512.png +0 -0
  333. package/public/icon.svg +4 -0
  334. package/public/icon_dark.svg +1 -0
  335. package/public/knotpad_icon.svg +1 -0
  336. package/public/knotpad_logo_full.svg +1 -0
  337. package/public/manifest.json +14 -0
  338. package/public/next.svg +1 -0
  339. package/public/sw.js +137 -0
  340. package/public/vercel.svg +1 -0
  341. package/public/window.svg +1 -0
  342. package/tsconfig.json +35 -0
@@ -0,0 +1,199 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import Stripe from "stripe";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { stripe } from "@/lib/stripe";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ export async function POST(req: NextRequest) {
9
+ const body = await req.text();
10
+ const sig = req.headers.get("stripe-signature");
11
+
12
+ if (!sig || !process.env.STRIPE_WEBHOOK_SECRET) {
13
+ return NextResponse.json({ error: "Missing signature" }, { status: 400 });
14
+ }
15
+
16
+ let event: Stripe.Event;
17
+ try {
18
+ event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET);
19
+ } catch {
20
+ return NextResponse.json({ error: "Webhook signature verification failed" }, { status: 400 });
21
+ }
22
+
23
+ // ── Idempotency guard ──────────────────────────────────────────────────────
24
+ // Stripe retries events for up to 3 days on non-2xx responses. Store the
25
+ // event ID before processing so replays are ignored without re-running logic.
26
+ const alreadyProcessed = await prisma.stripeWebhookEvent.findUnique({
27
+ where: { id: event.id },
28
+ });
29
+ if (alreadyProcessed) {
30
+ return NextResponse.json({ received: true });
31
+ }
32
+ await prisma.stripeWebhookEvent.create({
33
+ data: { id: event.id, type: event.type },
34
+ });
35
+ // ────────────────────────────────────────────────────────────────────────────
36
+
37
+ console.log(`[brief/webhook] processing event ${event.id} (${event.type})`);
38
+
39
+ switch (event.type) {
40
+ case "checkout.session.completed": {
41
+ const session = event.data.object as Stripe.Checkout.Session;
42
+ const workspaceId = session.metadata?.workspaceId;
43
+ const planType = session.metadata?.planType as "PERSONAL_PRO" | "TEAM_PRO" | undefined;
44
+ const subId = typeof session.subscription === "string" ? session.subscription : session.subscription?.id;
45
+
46
+ if (!workspaceId || !planType || !subId) {
47
+ console.warn(`[brief/webhook] checkout.session.completed missing metadata`, { workspaceId, planType, subId });
48
+ break;
49
+ }
50
+
51
+ const sub = await stripe.subscriptions.retrieve(subId);
52
+ const quantity = sub.items.data[0]?.quantity ?? 1;
53
+
54
+ await prisma.workspace.update({
55
+ where: { id: workspaceId },
56
+ data: {
57
+ planType,
58
+ isPro: true,
59
+ isCloud: true,
60
+ stripeSubId: subId,
61
+ seatCount: quantity,
62
+ },
63
+ });
64
+
65
+ // Fire-and-forget: copy local PGlite data to Neon for cloud-first mode.
66
+ // The migrate endpoint is idempotent — safe to retry.
67
+ const migrateUrl = `${process.env.NEXTAUTH_URL}/api/billing/migrate`;
68
+ fetch(migrateUrl, {
69
+ method: "POST",
70
+ headers: {
71
+ "Content-Type": "application/json",
72
+ Authorization: `Bearer ${process.env.CRON_SECRET ?? ""}`,
73
+ },
74
+ body: JSON.stringify({ workspaceId }),
75
+ signal: AbortSignal.timeout(10_000),
76
+ }).catch((err) => console.error("[brief/webhook] migration trigger failed:", err));
77
+
78
+ break;
79
+ }
80
+
81
+ case "customer.subscription.updated": {
82
+ const sub = event.data.object as Stripe.Subscription;
83
+ const workspaceId = sub.metadata?.workspaceId;
84
+ if (!workspaceId) {
85
+ console.warn(`[brief/webhook] subscription.updated missing workspaceId metadata`);
86
+ break;
87
+ }
88
+
89
+ const quantity = sub.items.data[0]?.quantity ?? 1;
90
+ const active = sub.status === "active" || sub.status === "trialing";
91
+
92
+ if (active) {
93
+ await prisma.workspace.update({
94
+ where: { id: workspaceId },
95
+ data: { seatCount: quantity },
96
+ });
97
+ } else {
98
+ // Subscription lapsed — downgrade workspace
99
+ await prisma.workspace.update({
100
+ where: { id: workspaceId },
101
+ data: {
102
+ planType: "FREE",
103
+ isPro: false,
104
+ isCloud: false,
105
+ stripeSubId: null,
106
+ },
107
+ });
108
+ }
109
+ break;
110
+ }
111
+
112
+ case "customer.subscription.deleted":
113
+ case "customer.subscription.paused": {
114
+ // Both deleted and paused mean the user loses access.
115
+ const sub = event.data.object as Stripe.Subscription;
116
+ const workspaceId = sub.metadata?.workspaceId;
117
+ if (!workspaceId) {
118
+ console.warn(`[brief/webhook] ${event.type} missing workspaceId metadata`);
119
+ break;
120
+ }
121
+
122
+ await prisma.workspace.update({
123
+ where: { id: workspaceId },
124
+ data: {
125
+ planType: "FREE",
126
+ isPro: false,
127
+ isCloud: false,
128
+ stripeSubId: null,
129
+ seatCount: 1,
130
+ },
131
+ });
132
+ break;
133
+ }
134
+
135
+ case "invoice.payment_failed": {
136
+ // Renewal failed — downgrade until payment is recovered.
137
+ // In API version 2026-04-22.dahlia, the subscription lives under parent.subscription_details.
138
+ const invoice = event.data.object as Stripe.Invoice;
139
+ const subRef = invoice.parent?.subscription_details?.subscription;
140
+ const subId = typeof subRef === "string" ? subRef : subRef?.id;
141
+ if (!subId) break;
142
+
143
+ const sub = await stripe.subscriptions.retrieve(subId);
144
+ const workspaceId = sub.metadata?.workspaceId;
145
+ if (!workspaceId) break;
146
+
147
+ await prisma.workspace.update({
148
+ where: { id: workspaceId },
149
+ data: {
150
+ planType: "FREE",
151
+ isPro: false,
152
+ isCloud: false,
153
+ stripeSubId: null,
154
+ seatCount: 1,
155
+ },
156
+ });
157
+ console.log(`[brief/webhook] workspace ${workspaceId} downgraded — invoice.payment_failed`);
158
+ break;
159
+ }
160
+
161
+ case "invoice.payment_succeeded": {
162
+ const invoice = event.data.object as Stripe.Invoice;
163
+ const subRef = invoice.parent?.subscription_details?.subscription;
164
+ const subId = typeof subRef === "string" ? subRef : subRef?.id;
165
+ if (!subId) break;
166
+
167
+ const sub = await stripe.subscriptions.retrieve(subId);
168
+ const workspaceId = sub.metadata?.workspaceId;
169
+ if (!workspaceId) break;
170
+
171
+ // Don't re-upgrade if already pro (first invoice fires this too)
172
+ const workspace = await prisma.workspace.findUnique({ where: { id: workspaceId } });
173
+ if (workspace?.isPro) break;
174
+
175
+ const quantity = sub.items.data[0]?.quantity ?? 1;
176
+ const planType = sub.metadata?.planType as "PERSONAL_PRO" | "TEAM_PRO" | undefined;
177
+ if (!planType) break;
178
+
179
+ await prisma.workspace.update({
180
+ where: { id: workspaceId },
181
+ data: {
182
+ planType,
183
+ isPro: true,
184
+ isCloud: true,
185
+ stripeSubId: subId,
186
+ seatCount: quantity,
187
+ },
188
+ });
189
+ console.log(`[brief/webhook] workspace ${workspaceId} re-activated — invoice.payment_succeeded`);
190
+ break;
191
+ }
192
+
193
+ default:
194
+ // Unknown event type — safe to ignore
195
+ break;
196
+ }
197
+
198
+ return NextResponse.json({ received: true });
199
+ }
@@ -0,0 +1,67 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { auth } from "@/auth";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { notFound, parseJson, unauthorized } from "@/lib/api-error";
5
+ import { updateFeedSchema } from "@/lib/validation/calendar-feed";
6
+ import { encryptFeedUrl } from "@/lib/calendar-feed-crypto";
7
+ import { generateSalt } from "@/lib/encryption";
8
+ import { fetchAndCacheFeed } from "@/lib/calendar-feed";
9
+
10
+ export async function PATCH(
11
+ req: NextRequest,
12
+ { params }: { params: Promise<{ feedId: string }> }
13
+ ) {
14
+ const session = await auth();
15
+ if (!session) return unauthorized();
16
+
17
+ const { feedId } = await params;
18
+ const feed = await prisma.calendarFeed.findFirst({
19
+ where: { id: feedId, userId: session.user.id },
20
+ });
21
+ if (!feed) return notFound();
22
+
23
+ const parsed = await parseJson(req, updateFeedSchema);
24
+ if (parsed.response) return parsed.response;
25
+ const { label, url, enabled, color } = parsed.data;
26
+
27
+ const data: { label?: string; enabled?: boolean; color?: string; encryptedUrl?: string; urlSalt?: string } = {};
28
+ if (label !== undefined) data.label = label;
29
+ if (enabled !== undefined) data.enabled = enabled;
30
+ if (color !== undefined) data.color = color;
31
+ if (url !== undefined) {
32
+ const salt = generateSalt();
33
+ data.encryptedUrl = await encryptFeedUrl(url, salt);
34
+ data.urlSalt = salt;
35
+ }
36
+
37
+ await prisma.calendarFeed.update({ where: { id: feedId }, data });
38
+
39
+ if (url !== undefined) {
40
+ await fetchAndCacheFeed(feedId);
41
+ }
42
+
43
+ const updated = await prisma.calendarFeed.findUnique({
44
+ where: { id: feedId },
45
+ select: { id: true, label: true, color: true, enabled: true, lastFetchedAt: true, lastError: true },
46
+ });
47
+
48
+ return NextResponse.json(updated);
49
+ }
50
+
51
+ export async function DELETE(
52
+ _req: NextRequest,
53
+ { params }: { params: Promise<{ feedId: string }> }
54
+ ) {
55
+ const session = await auth();
56
+ if (!session) return unauthorized();
57
+
58
+ const { feedId } = await params;
59
+ const feed = await prisma.calendarFeed.findFirst({
60
+ where: { id: feedId, userId: session.user.id },
61
+ });
62
+ if (!feed) return notFound();
63
+
64
+ await prisma.calendarFeed.delete({ where: { id: feedId } });
65
+
66
+ return NextResponse.json({ ok: true });
67
+ }
@@ -0,0 +1,37 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { auth } from "@/auth";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { notFound, unauthorized } from "@/lib/api-error";
5
+ import { fetchAndCacheFeed } from "@/lib/calendar-feed";
6
+ import { rateLimit } from "@/lib/rate-limit";
7
+
8
+ export async function POST(
9
+ req: NextRequest,
10
+ { params }: { params: Promise<{ feedId: string }> }
11
+ ) {
12
+ const session = await auth();
13
+ if (!session) return unauthorized();
14
+
15
+ const limit = rateLimit(`calendar-feed-sync:${session.user.id}`, 10, 60_000);
16
+ if (limit.limited) {
17
+ return NextResponse.json(
18
+ { error: "Too many sync requests, please wait a moment" },
19
+ { status: 429, headers: { "Retry-After": String(limit.retryAfter) } }
20
+ );
21
+ }
22
+
23
+ const { feedId } = await params;
24
+ const feed = await prisma.calendarFeed.findFirst({
25
+ where: { id: feedId, userId: session.user.id },
26
+ });
27
+ if (!feed) return notFound();
28
+
29
+ const result = await fetchAndCacheFeed(feedId);
30
+
31
+ const updated = await prisma.calendarFeed.findUnique({
32
+ where: { id: feedId },
33
+ select: { id: true, label: true, enabled: true, lastFetchedAt: true, lastError: true },
34
+ });
35
+
36
+ return NextResponse.json({ ...updated, syncOk: result.ok });
37
+ }
@@ -0,0 +1,82 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { auth } from "@/auth";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { apiError, unauthorized } from "@/lib/api-error";
5
+
6
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
7
+
8
+ export async function GET(req: NextRequest) {
9
+ const session = await auth();
10
+ if (!session) return unauthorized();
11
+
12
+ const { searchParams } = req.nextUrl;
13
+ const start = searchParams.get("start");
14
+ const end = searchParams.get("end");
15
+ if (!start || !end || !DATE_RE.test(start) || !DATE_RE.test(end)) {
16
+ return apiError("start and end query params required (YYYY-MM-DD)", 400);
17
+ }
18
+
19
+ const rangeStart = new Date(`${start}T00:00:00.000Z`);
20
+ const rangeEnd = new Date(`${end}T23:59:59.999Z`);
21
+
22
+ const events = await prisma.calendarFeedEvent.findMany({
23
+ where: {
24
+ feed: { userId: session.user.id, enabled: true },
25
+ start: { lte: rangeEnd },
26
+ end: { gte: rangeStart },
27
+ },
28
+ include: { feed: { select: { id: true, label: true, color: true } } },
29
+ orderBy: { start: "asc" },
30
+ });
31
+
32
+ const byDate: Record<
33
+ string,
34
+ { title: string; start: string; end: string; allDay: boolean; feedId: string; feedLabel: string; feedColor: string }[]
35
+ > = {};
36
+
37
+ for (const e of events) {
38
+ if (e.allDay) {
39
+ const eventStartStr = e.start.toISOString().slice(0, 10);
40
+ const eventEndStr = e.end.toISOString().slice(0, 10);
41
+ const overlapStartStr = eventStartStr < start ? start : eventStartStr;
42
+ const overlapEndStr = eventEndStr > end ? end : eventEndStr;
43
+
44
+ const cursor = new Date(`${overlapStartStr}T00:00:00.000Z`);
45
+ const limit = new Date(`${overlapEndStr}T00:00:00.000Z`);
46
+ while (cursor <= limit) {
47
+ const key = cursor.toISOString().slice(0, 10);
48
+ (byDate[key] ??= []).push({
49
+ title: e.title,
50
+ start: e.start.toISOString(),
51
+ end: e.end.toISOString(),
52
+ allDay: e.allDay,
53
+ feedId: e.feed.id,
54
+ feedLabel: e.feed.label,
55
+ feedColor: e.feed.color,
56
+ });
57
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
58
+ }
59
+ } else {
60
+ // Bucket multi-day events under each day they overlap within the range.
61
+ const dayCursor = new Date(Math.max(e.start.getTime(), rangeStart.getTime()));
62
+ dayCursor.setUTCHours(0, 0, 0, 0);
63
+ const last = new Date(Math.min(e.end.getTime(), rangeEnd.getTime()));
64
+
65
+ while (dayCursor <= last) {
66
+ const key = dayCursor.toISOString().slice(0, 10);
67
+ (byDate[key] ??= []).push({
68
+ title: e.title,
69
+ start: e.start.toISOString(),
70
+ end: e.end.toISOString(),
71
+ allDay: e.allDay,
72
+ feedId: e.feed.id,
73
+ feedLabel: e.feed.label,
74
+ feedColor: e.feed.color,
75
+ });
76
+ dayCursor.setUTCDate(dayCursor.getUTCDate() + 1);
77
+ }
78
+ }
79
+ }
80
+
81
+ return NextResponse.json({ events: byDate });
82
+ }
@@ -0,0 +1,52 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { auth } from "@/auth";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { apiError, parseJson, unauthorized } from "@/lib/api-error";
5
+ import { createFeedSchema } from "@/lib/validation/calendar-feed";
6
+ import { encryptFeedUrl } from "@/lib/calendar-feed-crypto";
7
+ import { generateSalt } from "@/lib/encryption";
8
+ import { fetchAndCacheFeed } from "@/lib/calendar-feed";
9
+ import { MAX_CALENDAR_FEEDS } from "@/lib/limits";
10
+
11
+ export async function GET() {
12
+ const session = await auth();
13
+ if (!session) return unauthorized();
14
+
15
+ const feeds = await prisma.calendarFeed.findMany({
16
+ where: { userId: session.user.id },
17
+ orderBy: { createdAt: "asc" },
18
+ select: { id: true, label: true, color: true, enabled: true, lastFetchedAt: true, lastError: true },
19
+ });
20
+
21
+ return NextResponse.json(feeds);
22
+ }
23
+
24
+ export async function POST(req: NextRequest) {
25
+ const session = await auth();
26
+ if (!session) return unauthorized();
27
+
28
+ const parsed = await parseJson(req, createFeedSchema);
29
+ if (parsed.response) return parsed.response;
30
+ const { label, url, color } = parsed.data;
31
+
32
+ const count = await prisma.calendarFeed.count({ where: { userId: session.user.id } });
33
+ if (count >= MAX_CALENDAR_FEEDS) {
34
+ return apiError(`You can connect up to ${MAX_CALENDAR_FEEDS} calendars`, 400);
35
+ }
36
+
37
+ const salt = generateSalt();
38
+ const encryptedUrl = await encryptFeedUrl(url, salt);
39
+
40
+ const feed = await prisma.calendarFeed.create({
41
+ data: { userId: session.user.id, label, encryptedUrl, urlSalt: salt, color: color ?? "sky" },
42
+ });
43
+
44
+ const result = await fetchAndCacheFeed(feed.id);
45
+
46
+ const updated = await prisma.calendarFeed.findUnique({
47
+ where: { id: feed.id },
48
+ select: { id: true, label: true, color: true, enabled: true, lastFetchedAt: true, lastError: true },
49
+ });
50
+
51
+ return NextResponse.json({ ...updated, syncOk: result.ok }, { status: 201 });
52
+ }
@@ -0,0 +1,34 @@
1
+ import { NextResponse } from "next/server";
2
+ import { auth } from "@/auth";
3
+ import { prisma } from "@/lib/prisma";
4
+ import { unauthorized } from "@/lib/api-error";
5
+ import { fetchAndCacheFeed } from "@/lib/calendar-feed";
6
+ import { rateLimit } from "@/lib/rate-limit";
7
+
8
+ /** POST /api/calendar-feeds/sync-all — refresh every enabled feed for the user. */
9
+ export async function POST() {
10
+ const session = await auth();
11
+ if (!session) return unauthorized();
12
+
13
+ const limit = rateLimit(`calendar-feed-sync-all:${session.user.id}`, 3, 60_000);
14
+ if (limit.limited) {
15
+ return NextResponse.json(
16
+ { error: "Too many sync requests, please wait a moment" },
17
+ { status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
18
+ );
19
+ }
20
+
21
+ const feeds = await prisma.calendarFeed.findMany({
22
+ where: { userId: session.user.id, enabled: true },
23
+ select: { id: true },
24
+ });
25
+
26
+ // Run syncs sequentially to avoid hammering upstream servers
27
+ const results: { id: string; ok: boolean; error?: string }[] = [];
28
+ for (const feed of feeds) {
29
+ const r = await fetchAndCacheFeed(feed.id);
30
+ results.push({ id: feed.id, ...r });
31
+ }
32
+
33
+ return NextResponse.json({ results });
34
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Cron-triggered calendar feed sync.
3
+ * Refreshes cached events for all enabled CalendarFeed subscriptions.
4
+ * Secured with CRON_SECRET header (same pattern as app/api/cron/sync/route.tsx).
5
+ */
6
+
7
+ import { NextRequest, NextResponse } from "next/server";
8
+ import { prisma } from "@/lib/prisma";
9
+ import { fetchAndCacheFeed } from "@/lib/calendar-feed";
10
+
11
+ export async function POST(req: NextRequest) {
12
+ const cronSecret = process.env.CRON_SECRET;
13
+ if (!cronSecret || req.headers.get("authorization") !== `Bearer ${cronSecret}`) {
14
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
15
+ }
16
+
17
+ const feeds = await prisma.calendarFeed.findMany({
18
+ where: { enabled: true },
19
+ select: { id: true },
20
+ });
21
+
22
+ const results = await Promise.allSettled(feeds.map((f) => fetchAndCacheFeed(f.id)));
23
+
24
+ const summary = results.map((r, i) => ({
25
+ feedId: feeds[i].id,
26
+ ok: r.status === "fulfilled" ? r.value.ok : false,
27
+ error: r.status === "fulfilled" ? r.value.error : String(r.reason),
28
+ }));
29
+
30
+ return NextResponse.json({ synced: summary.length, summary });
31
+ }
@@ -0,0 +1,51 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { prisma } from "@/lib/prisma";
3
+
4
+ // Called by a cron job or scheduled trigger every 15 minutes.
5
+ // Marks claimed tasks with no heartbeat for >4hrs as stale and auto-releases them.
6
+ // Protected by a simple CRON_SECRET header.
7
+ export async function POST(req: NextRequest) {
8
+ const cronSecret = process.env.CRON_SECRET;
9
+ if (!cronSecret || req.headers.get("authorization") !== `Bearer ${cronSecret}`) {
10
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+ }
12
+
13
+ const staleThreshold = new Date(Date.now() - 4 * 60 * 60 * 1000);
14
+
15
+ const staleTasks = await prisma.task.findMany({
16
+ where: {
17
+ status: { in: ["CLAIMED", "IN_PROGRESS"] },
18
+ claimedBy: { not: null },
19
+ lastHeartbeat: { lt: staleThreshold },
20
+ },
21
+ include: { workspace: { include: { members: { where: { role: "OWNER" } } } } },
22
+ });
23
+
24
+ let released = 0;
25
+ for (const task of staleTasks) {
26
+ const owner = task.workspace.members[0];
27
+ await prisma.$transaction(async (tx) => {
28
+ await tx.task.update({
29
+ where: { id: task.id },
30
+ data: { status: "OPEN", claimedBy: null, claimedByAlias: null, claimedAt: null, lastHeartbeat: null },
31
+ });
32
+ await tx.auditLog.create({
33
+ data: { taskId: task.id, action: "auto_released", detail: "auto-released after 4hr inactivity" },
34
+ });
35
+ if (owner) {
36
+ await tx.notification.create({
37
+ data: {
38
+ userId: owner.userId,
39
+ type: "stale_release",
40
+ title: `Task auto-released: "${task.title}"`,
41
+ body: "No heartbeat for 4 hours. Returned to agent pool.",
42
+ taskId: task.id,
43
+ },
44
+ });
45
+ }
46
+ });
47
+ released++;
48
+ }
49
+
50
+ return NextResponse.json({ released });
51
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Cron-triggered sync endpoint.
3
+ * Called every 5 minutes by the hosting platform's scheduler (Dokploy/Nixpacks cron).
4
+ * Secured with CRON_SECRET header to prevent unauthorized triggers.
5
+ */
6
+
7
+ import { NextRequest, NextResponse } from "next/server";
8
+ import { prisma } from "@/lib/prisma";
9
+ import { runProFlush } from "@/lib/pro-flush";
10
+
11
+ export async function POST(req: NextRequest) {
12
+ const cronSecret = process.env.CRON_SECRET;
13
+ if (!cronSecret || req.headers.get("authorization") !== `Bearer ${cronSecret}`) {
14
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
15
+ }
16
+
17
+ // Find all cloud-enabled pro workspaces
18
+ const workspaces = await prisma.workspace.findMany({
19
+ where: { isPro: true, isCloud: true },
20
+ select: { id: true },
21
+ });
22
+
23
+ const results = await Promise.allSettled(
24
+ workspaces.map((w) => runProFlush(w.id))
25
+ );
26
+
27
+ const summary = results.map((r, i) => ({
28
+ workspaceId: workspaces[i].id,
29
+ status: r.status === "fulfilled" ? r.value.status : "error",
30
+ error: r.status === "rejected" ? String(r.reason) : undefined,
31
+ }));
32
+
33
+ return NextResponse.json({ synced: summary.length, summary });
34
+ }
@@ -0,0 +1,25 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { auth } from "@/auth";
3
+ import { prisma } from "@/lib/prisma";
4
+
5
+ export async function DELETE(
6
+ _req: NextRequest,
7
+ { params }: { params: Promise<{ deviceId: string }> }
8
+ ) {
9
+ const session = await auth();
10
+ if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+
12
+ const { deviceId } = await params;
13
+
14
+ const device = await prisma.deviceSession.findUnique({ where: { id: deviceId } });
15
+ if (!device || device.userId !== session.user.id) {
16
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
17
+ }
18
+
19
+ await prisma.deviceSession.update({
20
+ where: { id: deviceId },
21
+ data: { revokedAt: new Date() },
22
+ });
23
+
24
+ return NextResponse.json({ ok: true });
25
+ }
@@ -0,0 +1,41 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { auth } from "@/auth";
3
+ import { prisma } from "@/lib/prisma";
4
+
5
+ export async function GET() {
6
+ const session = await auth();
7
+ if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
8
+
9
+ const devices = await prisma.deviceSession.findMany({
10
+ where: { userId: session.user.id, revokedAt: null },
11
+ orderBy: { lastActive: "desc" },
12
+ });
13
+
14
+ return NextResponse.json(devices.map((d) => ({
15
+ id: d.id,
16
+ deviceName: d.deviceName,
17
+ deviceId: d.deviceId,
18
+ lastActive: d.lastActive.toISOString(),
19
+ createdAt: d.createdAt.toISOString(),
20
+ })));
21
+ }
22
+
23
+ export async function POST(req: NextRequest) {
24
+ const session = await auth();
25
+ if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
26
+
27
+ const { deviceId, deviceName } = await req.json();
28
+ if (!deviceId) return NextResponse.json({ error: "deviceId required" }, { status: 400 });
29
+
30
+ const device = await prisma.deviceSession.upsert({
31
+ where: { deviceId },
32
+ create: {
33
+ userId: session.user.id,
34
+ deviceId,
35
+ deviceName: deviceName ?? "Unknown Device",
36
+ },
37
+ update: { lastActive: new Date(), revokedAt: null },
38
+ });
39
+
40
+ return NextResponse.json({ id: device.id });
41
+ }