@actuate-media/cms-core 0.21.0 → 0.23.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 (109) hide show
  1. package/dist/__tests__/actions/document-crud.test.js +74 -2
  2. package/dist/__tests__/actions/document-crud.test.js.map +1 -1
  3. package/dist/__tests__/api/cache-headers.test.d.ts +2 -0
  4. package/dist/__tests__/api/cache-headers.test.d.ts.map +1 -0
  5. package/dist/__tests__/api/cache-headers.test.js +135 -0
  6. package/dist/__tests__/api/cache-headers.test.js.map +1 -0
  7. package/dist/__tests__/api/collections-discovery.test.d.ts +2 -0
  8. package/dist/__tests__/api/collections-discovery.test.d.ts.map +1 -0
  9. package/dist/__tests__/api/collections-discovery.test.js +129 -0
  10. package/dist/__tests__/api/collections-discovery.test.js.map +1 -0
  11. package/dist/__tests__/api/comments-routes.test.d.ts +2 -0
  12. package/dist/__tests__/api/comments-routes.test.d.ts.map +1 -0
  13. package/dist/__tests__/api/comments-routes.test.js +266 -0
  14. package/dist/__tests__/api/comments-routes.test.js.map +1 -0
  15. package/dist/__tests__/api/notifications-routes.test.d.ts +2 -0
  16. package/dist/__tests__/api/notifications-routes.test.d.ts.map +1 -0
  17. package/dist/__tests__/api/notifications-routes.test.js +225 -0
  18. package/dist/__tests__/api/notifications-routes.test.js.map +1 -0
  19. package/dist/__tests__/api/preview-and-scheduling.test.js +53 -0
  20. package/dist/__tests__/api/preview-and-scheduling.test.js.map +1 -1
  21. package/dist/__tests__/fields/relations.test.js +62 -8
  22. package/dist/__tests__/fields/relations.test.js.map +1 -1
  23. package/dist/__tests__/preview-event-bus.test.d.ts +2 -0
  24. package/dist/__tests__/preview-event-bus.test.d.ts.map +1 -0
  25. package/dist/__tests__/preview-event-bus.test.js +89 -0
  26. package/dist/__tests__/preview-event-bus.test.js.map +1 -0
  27. package/dist/__tests__/preview-sse-stream.test.d.ts +2 -0
  28. package/dist/__tests__/preview-sse-stream.test.d.ts.map +1 -0
  29. package/dist/__tests__/preview-sse-stream.test.js +117 -0
  30. package/dist/__tests__/preview-sse-stream.test.js.map +1 -0
  31. package/dist/__tests__/realtime/comments.test.d.ts +2 -0
  32. package/dist/__tests__/realtime/comments.test.d.ts.map +1 -0
  33. package/dist/__tests__/realtime/comments.test.js +508 -0
  34. package/dist/__tests__/realtime/comments.test.js.map +1 -0
  35. package/dist/__tests__/realtime/gateway-ws-integration.test.d.ts +2 -0
  36. package/dist/__tests__/realtime/gateway-ws-integration.test.d.ts.map +1 -0
  37. package/dist/__tests__/realtime/gateway-ws-integration.test.js +170 -0
  38. package/dist/__tests__/realtime/gateway-ws-integration.test.js.map +1 -0
  39. package/dist/__tests__/realtime/gateway.test.d.ts +2 -0
  40. package/dist/__tests__/realtime/gateway.test.d.ts.map +1 -0
  41. package/dist/__tests__/realtime/gateway.test.js +402 -0
  42. package/dist/__tests__/realtime/gateway.test.js.map +1 -0
  43. package/dist/__tests__/realtime/notifications.test.d.ts +2 -0
  44. package/dist/__tests__/realtime/notifications.test.d.ts.map +1 -0
  45. package/dist/__tests__/realtime/notifications.test.js +317 -0
  46. package/dist/__tests__/realtime/notifications.test.js.map +1 -0
  47. package/dist/__tests__/realtime/persistence.test.d.ts +2 -0
  48. package/dist/__tests__/realtime/persistence.test.d.ts.map +1 -0
  49. package/dist/__tests__/realtime/persistence.test.js +124 -0
  50. package/dist/__tests__/realtime/persistence.test.js.map +1 -0
  51. package/dist/__tests__/schema-preview-features.test.d.ts +2 -0
  52. package/dist/__tests__/schema-preview-features.test.d.ts.map +1 -0
  53. package/dist/__tests__/schema-preview-features.test.js +42 -0
  54. package/dist/__tests__/schema-preview-features.test.js.map +1 -0
  55. package/dist/actions.d.ts.map +1 -1
  56. package/dist/actions.js +71 -26
  57. package/dist/actions.js.map +1 -1
  58. package/dist/api/handlers.d.ts.map +1 -1
  59. package/dist/api/handlers.js +772 -188
  60. package/dist/api/handlers.js.map +1 -1
  61. package/dist/cache/__tests__/http.test.d.ts +2 -0
  62. package/dist/cache/__tests__/http.test.d.ts.map +1 -0
  63. package/dist/cache/__tests__/http.test.js +285 -0
  64. package/dist/cache/__tests__/http.test.js.map +1 -0
  65. package/dist/cache/http.d.ts +119 -0
  66. package/dist/cache/http.d.ts.map +1 -0
  67. package/dist/cache/http.js +188 -0
  68. package/dist/cache/http.js.map +1 -0
  69. package/dist/config/types.d.ts +92 -0
  70. package/dist/config/types.d.ts.map +1 -1
  71. package/dist/fields/relations.d.ts.map +1 -1
  72. package/dist/fields/relations.js +43 -11
  73. package/dist/fields/relations.js.map +1 -1
  74. package/dist/index.d.ts +5 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +2 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/page-builder/schema.d.ts +2 -2
  79. package/dist/preview/event-bus.d.ts +39 -0
  80. package/dist/preview/event-bus.d.ts.map +1 -0
  81. package/dist/preview/event-bus.js +73 -0
  82. package/dist/preview/event-bus.js.map +1 -0
  83. package/dist/preview/sse-stream.d.ts +50 -0
  84. package/dist/preview/sse-stream.d.ts.map +1 -0
  85. package/dist/preview/sse-stream.js +120 -0
  86. package/dist/preview/sse-stream.js.map +1 -0
  87. package/dist/realtime/comments.d.ts +217 -0
  88. package/dist/realtime/comments.d.ts.map +1 -0
  89. package/dist/realtime/comments.js +282 -0
  90. package/dist/realtime/comments.js.map +1 -0
  91. package/dist/realtime/gateway.d.ts +107 -0
  92. package/dist/realtime/gateway.d.ts.map +1 -0
  93. package/dist/realtime/gateway.js +215 -0
  94. package/dist/realtime/gateway.js.map +1 -0
  95. package/dist/realtime/index.d.ts +18 -0
  96. package/dist/realtime/index.d.ts.map +1 -0
  97. package/dist/realtime/index.js +18 -0
  98. package/dist/realtime/index.js.map +1 -0
  99. package/dist/realtime/notifications.d.ts +209 -0
  100. package/dist/realtime/notifications.d.ts.map +1 -0
  101. package/dist/realtime/notifications.js +209 -0
  102. package/dist/realtime/notifications.js.map +1 -0
  103. package/dist/realtime/persistence.d.ts +72 -0
  104. package/dist/realtime/persistence.d.ts.map +1 -0
  105. package/dist/realtime/persistence.js +53 -0
  106. package/dist/realtime/persistence.js.map +1 -0
  107. package/package.json +13 -4
  108. package/prisma/cms-schema.prisma +135 -4
  109. package/prisma/schema.prisma +17 -3
@@ -11,7 +11,12 @@ import { optimizeImage, formatBytes } from '../media/optimize.js';
11
11
  import { generateToken as generateCsrfToken } from '../security/csrf.js';
12
12
  import { logEvent } from '../security/audit.js';
13
13
  import { applyFieldAccess } from '../security/access.js';
14
+ import { ACTUATE_TAG_ROOT, actuateTag, cachedRead, publicReadCacheControl } from '../cache/http.js';
14
15
  import { createPreviewAdapter } from '../preview/index.js';
16
+ import { createPreviewStreamResponse } from '../preview/sse-stream.js';
17
+ import { notifyPreviewUpdate } from '../preview/event-bus.js';
18
+ import { createComment as createCommentService, deleteComment as deleteCommentService, listComments as listCommentsService, reopenComment as reopenCommentService, resolveComment as resolveCommentService, updateComment as updateCommentService, } from '../realtime/comments.js';
19
+ import { createNotification, excerptForNotification, extractMentionedUserIds, listForUser as listNotificationsForUser, markAllRead as markAllNotificationsRead, markRead as markNotificationRead, unreadCount as notificationsUnreadCount, } from '../realtime/notifications.js';
15
20
  import { schedulingCronHandler } from '../scheduling/index.js';
16
21
  import { isAuthorizedCronRequest, processCleanup, processSeoScan } from '../cron/index.js';
17
22
  import { verifyCaptcha, getCaptchaConfig } from '../security/captcha.js';
@@ -92,6 +97,29 @@ function json(data, status = 200) {
92
97
  headers: { ...SECURITY_HEADERS },
93
98
  });
94
99
  }
100
+ /**
101
+ * Variant of `json()` that emits the Phase-5.5 public-read
102
+ * `Cache-Control` header when the active CMS config opts in.
103
+ *
104
+ * - Session-cookie requests always get `private, no-store`.
105
+ * - API-key / unauthenticated requests get
106
+ * `public, s-maxage=N, stale-while-revalidate=M` when
107
+ * `cache.publicReads.enabled` is true.
108
+ * - Otherwise no `Cache-Control` header is set (i.e. the response is
109
+ * uncacheable downstream, matching the pre-5.5 behaviour).
110
+ *
111
+ * Use this for the public read endpoints (`/collections/:slug`,
112
+ * `/collections/:slug/:id`, `/globals/:slug`, `/resolve`). Mutating
113
+ * endpoints stay on the plain `json()` so the CDN never caches a write
114
+ * response by accident.
115
+ */
116
+ function jsonWithCache(data, request, status = 200) {
117
+ const cacheControl = publicReadCacheControl(request, getActuateConfig()?.cache);
118
+ const headers = { ...SECURITY_HEADERS };
119
+ if (cacheControl)
120
+ headers['Cache-Control'] = cacheControl;
121
+ return new Response(JSON.stringify(data), { status, headers });
122
+ }
95
123
  function errorResponse(message, status) {
96
124
  return json({ error: message }, status);
97
125
  }
@@ -286,43 +314,73 @@ function renderOgSvg(opts) {
286
314
  <rect x="60" y="560" width="60" height="6" fill="${fg}" rx="3"/>
287
315
  </svg>`;
288
316
  }
317
+ /**
318
+ * Log unexpected query failures from the `safe*` wrappers.
319
+ *
320
+ * The wrappers exist so an optional Prisma model (e.g. a delegate
321
+ * that older schemas don't define) doesn't blow up the route — they
322
+ * return the empty value (`0` / `[]`) when the model isn't available.
323
+ * That defensive contract historically swallowed *every* error,
324
+ * including Prisma's "Unknown argument" rejections that flag a real
325
+ * application bug (see the `relationLoadStrategy` regression
326
+ * discovered while landing Phase 5.5 #5). Logging the underlying
327
+ * error preserves the defensive return semantics while making the
328
+ * actual class of bug visible in Vercel + Sentry logs.
329
+ *
330
+ * Kept private to this module — it's a logging contract, not a
331
+ * public API.
332
+ */
333
+ function logSafeQueryFailure(scope, err) {
334
+ // Defensive: never throw from a logger that wraps `safe*` queries
335
+ // — the whole point of those wrappers is to keep the request hot
336
+ // path alive.
337
+ try {
338
+ const message = err instanceof Error ? err.message : String(err);
339
+ console.warn(`[actuate][cms-api] ${scope} query failed: ${message}`);
340
+ }
341
+ catch {
342
+ /* swallow */
343
+ }
344
+ }
289
345
  async function safeCount(model, where) {
346
+ if (!model || typeof model !== 'object')
347
+ return 0;
348
+ let countFn;
290
349
  try {
291
- if (!model || typeof model !== 'object')
292
- return 0;
293
- let countFn;
294
- try {
295
- countFn = model.count;
296
- }
297
- catch {
298
- return 0;
299
- }
300
- if (typeof countFn !== 'function')
301
- return 0;
302
- return await countFn.call(model, where ? { where } : undefined);
350
+ countFn = model.count;
303
351
  }
304
352
  catch {
305
353
  return 0;
306
354
  }
355
+ if (typeof countFn !== 'function')
356
+ return 0;
357
+ try {
358
+ return await countFn.call(model, where ? { where } : undefined);
359
+ }
360
+ catch (err) {
361
+ logSafeQueryFailure('safeCount', err);
362
+ return 0;
363
+ }
307
364
  }
308
365
  async function safeFindMany(model, args) {
366
+ if (!model || typeof model !== 'object')
367
+ return [];
368
+ let findManyFn;
309
369
  try {
310
- if (!model || typeof model !== 'object')
311
- return [];
312
- let findManyFn;
313
- try {
314
- findManyFn = model.findMany;
315
- }
316
- catch {
317
- return [];
318
- }
319
- if (typeof findManyFn !== 'function')
320
- return [];
321
- return await findManyFn.call(model, args);
370
+ findManyFn = model.findMany;
322
371
  }
323
372
  catch {
324
373
  return [];
325
374
  }
375
+ if (typeof findManyFn !== 'function')
376
+ return [];
377
+ try {
378
+ return await findManyFn.call(model, args);
379
+ }
380
+ catch (err) {
381
+ logSafeQueryFailure('safeFindMany', err);
382
+ return [];
383
+ }
326
384
  }
327
385
  function isAllowedStorageUrl(url) {
328
386
  try {
@@ -718,6 +776,44 @@ export function registerCMSRoutes(router) {
718
776
  headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
719
777
  });
720
778
  });
779
+ // ---------------------------------------------------------------------------
780
+ // Collection discovery (Phase 7 PR F)
781
+ //
782
+ // Lightweight enumeration sourced directly from `getActuateConfig()` so
783
+ // agents and the MCP server's `list_collections` tool don't depend on
784
+ // OpenAPI spec generation succeeding. OpenAPI is still the source of
785
+ // truth for full per-field JSON Schemas; this endpoint trades depth
786
+ // for reliability and surfaces just enough metadata for the agent to
787
+ // plan a follow-up call (create_document, list_documents, etc.).
788
+ //
789
+ // Auth: unauthenticated, like /openapi.json — the response contains
790
+ // only schema-shape metadata that is already public via OpenAPI.
791
+ // ---------------------------------------------------------------------------
792
+ router.get('/collections', async () => {
793
+ const config = getActuateConfig();
794
+ if (!config)
795
+ return errorResponse('CMS not configured', 500);
796
+ const collections = Object.entries(config.collections ?? {}).map(([slug, def]) => {
797
+ const fields = (def?.fields ?? {});
798
+ // Reduce each field to `{ name, type, required }` — enough for the
799
+ // agent to plan a create/update without sending the full validator.
800
+ const fieldSummary = Object.entries(fields).map(([name, fieldDef]) => ({
801
+ name,
802
+ type: typeof fieldDef?.type === 'string' ? fieldDef.type : 'unknown',
803
+ required: fieldDef?.required === true,
804
+ }));
805
+ return {
806
+ slug,
807
+ labels: def?.labels ?? { singular: slug, plural: slug },
808
+ type: def?.type ?? 'page',
809
+ urlPrefix: def?.urlPrefix ?? slug,
810
+ hidden: def?.admin?.hidden === true,
811
+ fieldCount: fieldSummary.length,
812
+ fields: fieldSummary,
813
+ };
814
+ });
815
+ return json({ data: collections });
816
+ });
721
817
  router.get('/docs', async () => {
722
818
  const html = `<!DOCTYPE html>
723
819
  <html>
@@ -1451,33 +1547,40 @@ export function registerCMSRoutes(router) {
1451
1547
  const scopeErr = requireCollectionScope(auth.session, params.slug, 'read');
1452
1548
  if (scopeErr)
1453
1549
  return scopeErr;
1454
- const url = new URL(request.url);
1455
- const ctx = buildActionContext(auth.session, db(), url.searchParams.get('locale') ?? undefined);
1456
- const result = await listDocuments({
1457
- collection: params.slug,
1458
- page: Number(url.searchParams.get('page')) || 1,
1459
- pageSize: clampPageSize(url.searchParams.get('pageSize')),
1460
- sort: url.searchParams.get('sort') ?? undefined,
1461
- order: url.searchParams.get('order') ?? undefined,
1462
- status: url.searchParams.get('status') ?? undefined,
1463
- locale: url.searchParams.get('locale') ?? undefined,
1464
- folderId: url.searchParams.get('folderId') ?? undefined,
1465
- }, ctx);
1466
- const collectionConfig = getActuateConfig()?.collections?.[params.slug];
1467
- const fields = collectionConfig?.fields;
1468
- if (fields && result.docs.length > 0) {
1469
- const user = { id: auth.session.userId, role: auth.session.role };
1470
- result.docs = await Promise.all(result.docs.map(async (doc) => {
1471
- if (doc.data && typeof doc.data === 'object') {
1472
- return {
1473
- ...doc,
1474
- data: await applyFieldAccess('read', fields, doc.data, user),
1475
- };
1550
+ return cachedRead(request, {
1551
+ cache: getActuateConfig()?.cache,
1552
+ tags: [ACTUATE_TAG_ROOT, actuateTag('collection', params.slug)],
1553
+ wrap: jsonWithCache,
1554
+ build: async () => {
1555
+ const url = new URL(request.url);
1556
+ const ctx = buildActionContext(auth.session, db(), url.searchParams.get('locale') ?? undefined);
1557
+ const result = await listDocuments({
1558
+ collection: params.slug,
1559
+ page: Number(url.searchParams.get('page')) || 1,
1560
+ pageSize: clampPageSize(url.searchParams.get('pageSize')),
1561
+ sort: url.searchParams.get('sort') ?? undefined,
1562
+ order: url.searchParams.get('order') ?? undefined,
1563
+ status: url.searchParams.get('status') ?? undefined,
1564
+ locale: url.searchParams.get('locale') ?? undefined,
1565
+ folderId: url.searchParams.get('folderId') ?? undefined,
1566
+ }, ctx);
1567
+ const collectionConfig = getActuateConfig()?.collections?.[params.slug];
1568
+ const fields = collectionConfig?.fields;
1569
+ if (fields && result.docs.length > 0) {
1570
+ const user = { id: auth.session.userId, role: auth.session.role };
1571
+ result.docs = await Promise.all(result.docs.map(async (doc) => {
1572
+ if (doc.data && typeof doc.data === 'object') {
1573
+ return {
1574
+ ...doc,
1575
+ data: await applyFieldAccess('read', fields, doc.data, user),
1576
+ };
1577
+ }
1578
+ return doc;
1579
+ }));
1476
1580
  }
1477
- return doc;
1478
- }));
1479
- }
1480
- return json({ data: result });
1581
+ return { data: result };
1582
+ },
1583
+ });
1481
1584
  }
1482
1585
  catch (err) {
1483
1586
  return internalError(err);
@@ -1491,22 +1594,36 @@ export function registerCMSRoutes(router) {
1491
1594
  const scopeErr = requireCollectionScope(auth.session, params.slug, 'read');
1492
1595
  if (scopeErr)
1493
1596
  return scopeErr;
1494
- const ctx = buildActionContext(auth.session, db());
1495
- const doc = await getDocument(params.slug, params.id, ctx);
1496
- if (!doc) {
1497
- return errorResponse('Document not found', 404);
1498
- }
1499
- // `getDocument` already lifts `_layout` / `_pageSettings` to the
1500
- // top-level page builder envelope and strips internal keys from
1501
- // `data`. Field-level access has been applied as well — we don't
1502
- // need to re-apply it here.
1503
- //
1504
- // Opt-in relation population: ?populate=author,relatedPosts (or *)
1505
- // replaces stored IDs with `{ id, title, slug, status, collection }`
1506
- // summaries. We only run this when the caller asked because it's an
1507
- // extra DB roundtrip and most internal callers don't need it.
1508
- const populated = await maybePopulate(request, params.slug, doc, db());
1509
- return json({ data: populated });
1597
+ return cachedRead(request, {
1598
+ cache: getActuateConfig()?.cache,
1599
+ tags: [
1600
+ ACTUATE_TAG_ROOT,
1601
+ actuateTag('collection', params.slug),
1602
+ actuateTag('doc', params.id),
1603
+ ],
1604
+ wrap: jsonWithCache,
1605
+ build: async () => {
1606
+ const ctx = buildActionContext(auth.session, db());
1607
+ const doc = await getDocument(params.slug, params.id, ctx);
1608
+ if (!doc) {
1609
+ // Returning a Response short-circuits the cache layer we
1610
+ // never cache 404s. The next request gets a fresh DB lookup
1611
+ // so a doc that's restored from soft-delete is visible
1612
+ // immediately.
1613
+ return errorResponse('Document not found', 404);
1614
+ }
1615
+ // `getDocument` already lifts `_layout` / `_pageSettings` to
1616
+ // the top-level page builder envelope and strips internal keys
1617
+ // from `data`. Field-level access has been applied as well.
1618
+ //
1619
+ // Opt-in relation population (?populate=author,relatedPosts |
1620
+ // *) replaces stored IDs with reference summaries. The
1621
+ // `?populate=` param is part of the cache key (URL-derived),
1622
+ // so populated and non-populated reads cache independently.
1623
+ const populated = await maybePopulate(request, params.slug, doc, db());
1624
+ return { data: populated };
1625
+ },
1626
+ });
1510
1627
  }
1511
1628
  catch (err) {
1512
1629
  return internalError(err);
@@ -1550,6 +1667,13 @@ export function registerCMSRoutes(router) {
1550
1667
  userId: auth.session.userId,
1551
1668
  details: { collection: params.slug, documentId: params.id },
1552
1669
  });
1670
+ // Push to any open live-preview SSE streams. No-op when no admin is
1671
+ // watching the document; the bus drops the call cheaply.
1672
+ notifyPreviewUpdate(params.slug, params.id, {
1673
+ values: doc.data,
1674
+ status: doc.status,
1675
+ updatedAt: doc.updatedAt?.toISOString?.(),
1676
+ });
1553
1677
  return json({ data: doc });
1554
1678
  }
1555
1679
  catch (err) {
@@ -2395,23 +2519,24 @@ export function registerCMSRoutes(router) {
2395
2519
  recentDocuments: [],
2396
2520
  };
2397
2521
  async function safeGroupBy(model, by, where) {
2522
+ if (!model || typeof model !== 'object')
2523
+ return [];
2524
+ let groupByFn;
2398
2525
  try {
2399
- if (!model || typeof model !== 'object')
2400
- return [];
2401
- let groupByFn;
2402
- try {
2403
- groupByFn = model.groupBy;
2404
- }
2405
- catch {
2406
- return [];
2407
- }
2408
- if (typeof groupByFn !== 'function')
2409
- return [];
2410
- return await groupByFn.call(model, { by, where: where ?? {}, _count: { _all: true } });
2526
+ groupByFn = model.groupBy;
2411
2527
  }
2412
2528
  catch {
2413
2529
  return [];
2414
2530
  }
2531
+ if (typeof groupByFn !== 'function')
2532
+ return [];
2533
+ try {
2534
+ return await groupByFn.call(model, { by, where: where ?? {}, _count: { _all: true } });
2535
+ }
2536
+ catch (err) {
2537
+ logSafeQueryFailure('safeGroupBy', err);
2538
+ return [];
2539
+ }
2415
2540
  }
2416
2541
  function computeLightSeoScore(data) {
2417
2542
  let score = 0;
@@ -2444,6 +2569,12 @@ export function registerCMSRoutes(router) {
2444
2569
  safeCount(d.media),
2445
2570
  safeCount(d.user),
2446
2571
  safeFindMany(d.document, {
2572
+ // Prisma 7's `'join'` strategy folds the User relation into a
2573
+ // single LATERAL SQL query so the dashboard read costs one
2574
+ // round-trip instead of two. On the production Vercel Function
2575
+ // -> Prisma Postgres path that saves ~80 ms (one full network
2576
+ // RTT) on every dashboard load.
2577
+ relationLoadStrategy: 'join',
2447
2578
  where: { deletedAt: null },
2448
2579
  orderBy: { updatedAt: 'desc' },
2449
2580
  take: 20,
@@ -4044,6 +4175,12 @@ export function registerCMSRoutes(router) {
4044
4175
  // verify the URL actually points at the document the token was
4045
4176
  // minted for; this prevents using one token to peek at unrelated
4046
4177
  // drafts in the same collection.
4178
+ //
4179
+ // Validation happens BEFORE the runtime-cache lookup so a preview
4180
+ // session can never read from (or write to) the shared cache. The
4181
+ // small price is that every `/resolve?preview=` call runs a JWT
4182
+ // verification even on what would otherwise be a cache hit —
4183
+ // worth it; the alternative is a cache poisoned with draft data.
4047
4184
  const previewToken = url.searchParams.get('preview');
4048
4185
  let previewSession = null;
4049
4186
  if (previewToken) {
@@ -4063,112 +4200,135 @@ export function registerCMSRoutes(router) {
4063
4200
  }
4064
4201
  }
4065
4202
  const isRootPath = segments.length === 0;
4066
- const slugFilter = {
4067
- OR: isRootPath
4068
- ? [
4069
- { data: { path: ['slug'], equals: 'home' } },
4070
- { data: { path: ['slug'], equals: 'index' } },
4071
- { slug: 'home' },
4072
- { slug: 'index' },
4073
- ]
4074
- : [{ data: { path: ['slug'], equals: docSlug } }, { slug: docSlug }],
4075
- };
4076
- const doc = previewSession
4077
- ? await db().document.findFirst({
4078
- where: {
4079
- collection: matchedCollection,
4080
- deletedAt: null,
4081
- ...slugFilter,
4082
- },
4083
- })
4084
- : await db().document.findFirst({
4085
- where: {
4086
- collection: matchedCollection,
4087
- deletedAt: null,
4088
- status: 'PUBLISHED',
4089
- ...slugFilter,
4090
- },
4091
- });
4092
- if (!doc) {
4093
- return errorResponse('Document not found', 404);
4094
- }
4095
- // Enforce the token ↔ document binding. A leaked or copied token can
4096
- // only unlock the exact document it was issued for.
4097
- if (previewSession &&
4098
- (previewSession.documentId !== doc.id || previewSession.collection !== matchedCollection)) {
4099
- return errorResponse('Preview token does not match this URL', 403);
4100
- }
4101
- let docData = doc.data && typeof doc.data === 'object' ? doc.data : {};
4102
- // Opt-in: ?populate=author,relatedPosts (or *) — expand relation IDs
4103
- // into `{ id, title, slug, status, collection }` summaries so the
4104
- // frontend renders authors / related case studies without a second
4105
- // request per reference. `resolve` is the highest-leverage place to
4106
- // do this because it's what marketing pages call from SSR.
4107
- const populateOpts = parsePopulateParam(new URL(request.url).searchParams.get('populate'));
4108
- if (populateOpts) {
4109
- const fields = getActuateConfig()?.collections?.[matchedCollection]?.fields;
4110
- if (fields) {
4111
- try {
4112
- docData = await populateRelations(fields, docData, buildRelationLookup(db()), populateOpts);
4113
- }
4114
- catch (err) {
4115
- console.error('[actuate][api] resolve populate failed:', err);
4116
- }
4117
- }
4118
- }
4119
- const layout = await resolveLayout(pathParam, docData, matchedCollection);
4120
- const { _layout: _omit, ...cleanData } = docData;
4121
- // Compose page meta + JSON-LD up front so client renderers (Next.js
4122
- // generateMetadata, plain SSR, MCP agents) don't have to re-derive
4123
- // schema, OG tags, and canonical URLs from raw doc data. Reads the
4124
- // collection's SEO config and the site-wide SEO defaults — with
4125
- // DB overrides from /seo/config applied on top of the static config.
4126
- const cfg = await loadEffectiveConfig();
4127
- const collectionDef = cfg?.collections?.[matchedCollection] ?? null;
4128
- const { composePageMeta } = await import('../seo/page-meta.js');
4129
- const composed = composePageMeta({
4130
- doc: {
4131
- id: doc.id,
4132
- collection: doc.collection,
4133
- slug: doc.slug ?? cleanData.slug ?? null,
4134
- data: cleanData,
4135
- publishedAt: doc.publishedAt,
4136
- updatedAt: doc.updatedAt,
4137
- structuredData: doc.structuredData ?? null,
4138
- pageSettings: cleanData.pageSettings ?? null,
4139
- },
4140
- collection: collectionDef,
4141
- config: cfg ?? null,
4142
- siteUrl: siteUrlFromRequest(request, cfg),
4143
- });
4144
- return json({
4145
- data: {
4146
- id: doc.id,
4147
- collection: doc.collection,
4148
- data: cleanData,
4149
- status: doc.status,
4150
- publishedAt: doc.publishedAt,
4151
- structuredData: composed.jsonLd ?? doc.structuredData,
4152
- },
4153
- meta: {
4154
- title: composed.title,
4155
- description: composed.description,
4156
- canonical: composed.canonical,
4157
- url: composed.url,
4158
- tags: composed.meta,
4159
- html: composed.metaHtml,
4160
- },
4161
- jsonLd: composed.jsonLd,
4162
- jsonLdHtml: composed.jsonLdHtml,
4163
- ...(Object.keys(layout).length > 0 ? { layout } : {}),
4164
- ...(previewSession
4165
- ? {
4166
- preview: {
4167
- active: true,
4168
- expiresAt: previewSession.expiresAt.toISOString(),
4203
+ // Heavy compute (doc lookup → populate → meta composition) lives in
4204
+ // a closure so the optional runtime-cache layer can short-circuit
4205
+ // it on a hit. The cache layer is bypassed entirely when a preview
4206
+ // session is active a draft must never enter the shared cache.
4207
+ const buildResolveBody = async () => {
4208
+ const slugFilter = {
4209
+ OR: isRootPath
4210
+ ? [
4211
+ { data: { path: ['slug'], equals: 'home' } },
4212
+ { data: { path: ['slug'], equals: 'index' } },
4213
+ { slug: 'home' },
4214
+ { slug: 'index' },
4215
+ ]
4216
+ : [{ data: { path: ['slug'], equals: docSlug } }, { slug: docSlug }],
4217
+ };
4218
+ const doc = previewSession
4219
+ ? await db().document.findFirst({
4220
+ where: {
4221
+ collection: matchedCollection,
4222
+ deletedAt: null,
4223
+ ...slugFilter,
4224
+ },
4225
+ })
4226
+ : await db().document.findFirst({
4227
+ where: {
4228
+ collection: matchedCollection,
4229
+ deletedAt: null,
4230
+ status: 'PUBLISHED',
4231
+ ...slugFilter,
4169
4232
  },
4233
+ });
4234
+ if (!doc) {
4235
+ return errorResponse('Document not found', 404);
4236
+ }
4237
+ // Enforce the token ↔ document binding. A leaked or copied token can
4238
+ // only unlock the exact document it was issued for.
4239
+ if (previewSession &&
4240
+ (previewSession.documentId !== doc.id || previewSession.collection !== matchedCollection)) {
4241
+ return errorResponse('Preview token does not match this URL', 403);
4242
+ }
4243
+ let docData = doc.data && typeof doc.data === 'object' ? doc.data : {};
4244
+ // Opt-in: ?populate=author,relatedPosts (or *) — expand relation IDs
4245
+ // into `{ id, title, slug, status, collection }` summaries so the
4246
+ // frontend renders authors / related case studies without a second
4247
+ // request per reference. `resolve` is the highest-leverage place to
4248
+ // do this because it's what marketing pages call from SSR.
4249
+ const populateOpts = parsePopulateParam(new URL(request.url).searchParams.get('populate'));
4250
+ if (populateOpts) {
4251
+ const fields = getActuateConfig()?.collections?.[matchedCollection]?.fields;
4252
+ if (fields) {
4253
+ try {
4254
+ docData = await populateRelations(fields, docData, buildRelationLookup(db()), populateOpts);
4255
+ }
4256
+ catch (err) {
4257
+ console.error('[actuate][api] resolve populate failed:', err);
4258
+ }
4170
4259
  }
4171
- : {}),
4260
+ }
4261
+ const layout = await resolveLayout(pathParam, docData, matchedCollection);
4262
+ const { _layout: _omit, ...cleanData } = docData;
4263
+ // Compose page meta + JSON-LD up front so client renderers (Next.js
4264
+ // generateMetadata, plain SSR, MCP agents) don't have to re-derive
4265
+ // schema, OG tags, and canonical URLs from raw doc data. Reads the
4266
+ // collection's SEO config and the site-wide SEO defaults — with
4267
+ // DB overrides from /seo/config applied on top of the static config.
4268
+ const cfg = await loadEffectiveConfig();
4269
+ const collectionDef = cfg?.collections?.[matchedCollection] ?? null;
4270
+ const { composePageMeta } = await import('../seo/page-meta.js');
4271
+ const composed = composePageMeta({
4272
+ doc: {
4273
+ id: doc.id,
4274
+ collection: doc.collection,
4275
+ slug: doc.slug ?? cleanData.slug ?? null,
4276
+ data: cleanData,
4277
+ publishedAt: doc.publishedAt,
4278
+ updatedAt: doc.updatedAt,
4279
+ structuredData: doc.structuredData ?? null,
4280
+ pageSettings: cleanData.pageSettings ?? null,
4281
+ },
4282
+ collection: collectionDef,
4283
+ config: cfg ?? null,
4284
+ siteUrl: siteUrlFromRequest(request, cfg),
4285
+ });
4286
+ return {
4287
+ data: {
4288
+ id: doc.id,
4289
+ collection: doc.collection,
4290
+ data: cleanData,
4291
+ status: doc.status,
4292
+ publishedAt: doc.publishedAt,
4293
+ structuredData: composed.jsonLd ?? doc.structuredData,
4294
+ },
4295
+ meta: {
4296
+ title: composed.title,
4297
+ description: composed.description,
4298
+ canonical: composed.canonical,
4299
+ url: composed.url,
4300
+ tags: composed.meta,
4301
+ html: composed.metaHtml,
4302
+ },
4303
+ jsonLd: composed.jsonLd,
4304
+ jsonLdHtml: composed.jsonLdHtml,
4305
+ ...(Object.keys(layout).length > 0 ? { layout } : {}),
4306
+ ...(previewSession
4307
+ ? {
4308
+ preview: {
4309
+ active: true,
4310
+ expiresAt: previewSession.expiresAt.toISOString(),
4311
+ },
4312
+ }
4313
+ : {}),
4314
+ };
4315
+ };
4316
+ // Preview-token requests bypass the cache (CDN + Runtime) so a
4317
+ // leaked cached preview can never show up for anonymous traffic.
4318
+ // Anonymous reads flow through cachedRead — coarse tags (the
4319
+ // wholesale `actuate` tag + a path tag) so every write drains
4320
+ // every cached resolve entry. Per-doc tagging would require
4321
+ // resolving the doc id BEFORE the cache lookup, which defeats
4322
+ // the purpose of caching at all.
4323
+ if (previewSession) {
4324
+ const body = await buildResolveBody();
4325
+ return body instanceof Response ? body : json(body);
4326
+ }
4327
+ return cachedRead(request, {
4328
+ cache: getActuateConfig()?.cache,
4329
+ tags: [ACTUATE_TAG_ROOT, actuateTag('path', pathParam)],
4330
+ wrap: jsonWithCache,
4331
+ build: buildResolveBody,
4172
4332
  });
4173
4333
  }
4174
4334
  catch (err) {
@@ -4433,6 +4593,418 @@ export function registerCMSRoutes(router) {
4433
4593
  return internalError(err, 'fetch preview data');
4434
4594
  }
4435
4595
  });
4596
+ /**
4597
+ * Server-Sent Events stream for live preview. The admin's `LivePreview`
4598
+ * panel opens an `EventSource('/api/cms/preview/stream?token=...')` and
4599
+ * relays every `event: update` frame to the previewed iframe through
4600
+ * `postMessage`. Token-based auth (instead of cookies) because
4601
+ * `EventSource` cannot send arbitrary headers.
4602
+ *
4603
+ * Emits:
4604
+ * - `event: ready` once on connection (handshake).
4605
+ * - `event: update` whenever the underlying draft document is updated
4606
+ * (see `PUT /collections/:slug/:id` for the emit site).
4607
+ * - `: heartbeat\n\n` keepalive comments every 25 s so proxies don't
4608
+ * reap idle connections.
4609
+ *
4610
+ * Closes cleanly after 4 minutes so platform idle caps don't terminate
4611
+ * the connection mid-event; EventSource will auto-reconnect.
4612
+ */
4613
+ router.get('/preview/stream', async (request) => {
4614
+ try {
4615
+ const url = new URL(request.url);
4616
+ const token = url.searchParams.get('token');
4617
+ if (!token)
4618
+ return errorResponse('Preview token required', 401);
4619
+ const preview = createPreviewAdapter(getSessionSecret(), db());
4620
+ const session = await preview.validatePreviewToken(token);
4621
+ if (!session)
4622
+ return errorResponse('Invalid or expired preview token', 401);
4623
+ return createPreviewStreamResponse(session);
4624
+ }
4625
+ catch (err) {
4626
+ return internalError(err, 'open preview stream');
4627
+ }
4628
+ });
4629
+ // ---------------------------------------------------------------------------
4630
+ // Comments — Phase 3 / slice 4
4631
+ //
4632
+ // Threaded, anchored discussion attached to documents. The Yjs gateway
4633
+ // already lives on `/realtime/sync`; comments are *not* sent over that
4634
+ // wire — they're plain REST so plugins, mobile clients, and notification
4635
+ // workers can consume them without speaking the CRDT protocol.
4636
+ //
4637
+ // Routes (all rewritten paths, no `/api/cms` prefix):
4638
+ // POST /documents/:documentId/comments — create top-level / reply
4639
+ // GET /documents/:documentId/comments — list (filters: resolved, deleted, pageSize)
4640
+ // PATCH /comments/:id — edit body (author / admin)
4641
+ // POST /comments/:id/resolve — resolve thread (write-role / author)
4642
+ // POST /comments/:id/reopen — re-open a resolved thread
4643
+ // DELETE /comments/:id — soft-delete (author / admin)
4644
+ //
4645
+ // The service module in `realtime/comments.ts` owns validation +
4646
+ // permissions; these handlers are thin envelopes that map auth → service
4647
+ // call → JSON response.
4648
+ // ---------------------------------------------------------------------------
4649
+ function mapCommentsErrorStatus(error) {
4650
+ switch (error.code) {
4651
+ case 'validation':
4652
+ return 400;
4653
+ case 'not_found':
4654
+ return 404;
4655
+ case 'forbidden':
4656
+ return 403;
4657
+ case 'conflict':
4658
+ return 409;
4659
+ }
4660
+ }
4661
+ function respondCommentsError(error) {
4662
+ return errorResponse(error.message, mapCommentsErrorStatus(error));
4663
+ }
4664
+ function commentsDb() {
4665
+ return db();
4666
+ }
4667
+ function notificationsDb() {
4668
+ return db();
4669
+ }
4670
+ function mapNotificationsErrorStatus(error) {
4671
+ if (error.code === 'validation')
4672
+ return 400;
4673
+ if (error.code === 'forbidden')
4674
+ return 403;
4675
+ if (error.code === 'not_found')
4676
+ return 404;
4677
+ return 500;
4678
+ }
4679
+ function respondNotificationsError(error) {
4680
+ return errorResponse(error.message, mapNotificationsErrorStatus(error));
4681
+ }
4682
+ /**
4683
+ * Lookup helper used by the notification triggers below. We need the
4684
+ * comment's `userId` (and, for replies, the *parent* comment's userId)
4685
+ * so we can decide who to notify. The lookup is best-effort: if the
4686
+ * row is missing (e.g. the comment was deleted between create and
4687
+ * trigger), we silently skip the notification.
4688
+ */
4689
+ async function findCommentRecipient(commentId) {
4690
+ try {
4691
+ const client = db();
4692
+ if (!client.documentComment)
4693
+ return null;
4694
+ return await client.documentComment.findUnique({ where: { id: commentId } });
4695
+ }
4696
+ catch {
4697
+ return null;
4698
+ }
4699
+ }
4700
+ /**
4701
+ * Issue notifications for a new comment. Two paths:
4702
+ *
4703
+ * - **Reply** — the actor replied to someone else's thread → notify
4704
+ * the root comment's author with kind `comment_reply`.
4705
+ * - **Mentions** — any `@<userId>` token in the body → notify each
4706
+ * mentioned user (other than the actor) with kind `comment_mention`.
4707
+ */
4708
+ async function notifyOnCommentCreated(opts) {
4709
+ const ndb = notificationsDb();
4710
+ if (opts.parentId) {
4711
+ const root = await findCommentRecipient(opts.parentId);
4712
+ if (root && root.userId && root.userId !== opts.actorId) {
4713
+ await createNotification(ndb, {
4714
+ userId: root.userId,
4715
+ documentId: opts.documentId,
4716
+ commentId: opts.commentId,
4717
+ payload: {
4718
+ kind: 'comment_reply',
4719
+ actorId: opts.actorId,
4720
+ actorName: opts.actorName,
4721
+ excerpt: excerptForNotification(opts.body),
4722
+ },
4723
+ });
4724
+ }
4725
+ }
4726
+ for (const userId of extractMentionedUserIds(opts.body)) {
4727
+ if (userId === opts.actorId)
4728
+ continue;
4729
+ await createNotification(ndb, {
4730
+ userId,
4731
+ documentId: opts.documentId,
4732
+ commentId: opts.commentId,
4733
+ payload: {
4734
+ kind: 'comment_mention',
4735
+ actorId: opts.actorId,
4736
+ actorName: opts.actorName,
4737
+ excerpt: excerptForNotification(opts.body),
4738
+ },
4739
+ });
4740
+ }
4741
+ }
4742
+ /** Notify the comment's author when someone else resolves their thread. */
4743
+ async function notifyOnCommentResolved(opts) {
4744
+ const comment = await findCommentRecipient(opts.commentId);
4745
+ if (!comment || !comment.userId || comment.userId === opts.actorId)
4746
+ return;
4747
+ await createNotification(notificationsDb(), {
4748
+ userId: comment.userId,
4749
+ commentId: opts.commentId,
4750
+ payload: {
4751
+ kind: 'comment_resolved',
4752
+ actorId: opts.actorId,
4753
+ actorName: opts.actorName,
4754
+ rootExcerpt: excerptForNotification(comment.body),
4755
+ },
4756
+ });
4757
+ }
4758
+ router.post('/documents/:documentId/comments', async (request, params) => {
4759
+ try {
4760
+ const auth = await requireAuth(request);
4761
+ if (auth.error)
4762
+ return auth.error;
4763
+ // Posting a comment requires at least write access; comments mutate
4764
+ // collaborative state and we don't want anonymous read-only tokens
4765
+ // (API keys without write scope) leaving discussion behind.
4766
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4767
+ if (roleErr)
4768
+ return roleErr;
4769
+ const body = (await request.json());
4770
+ const result = await createCommentService(commentsDb(), {
4771
+ documentId: params.documentId,
4772
+ userId: auth.session.userId,
4773
+ body: typeof body.body === 'string' ? body.body : '',
4774
+ anchor: body.anchor,
4775
+ parentId: typeof body.parentId === 'string' ? body.parentId : null,
4776
+ });
4777
+ if (!result.ok)
4778
+ return respondCommentsError(result.error);
4779
+ await logEvent({
4780
+ event: 'comment_created',
4781
+ userId: auth.session.userId,
4782
+ details: {
4783
+ documentId: params.documentId,
4784
+ commentId: result.value.id,
4785
+ parentId: result.value.parentId,
4786
+ },
4787
+ });
4788
+ // Fire-and-forget — a notification failure should never block the
4789
+ // comment from being saved. Errors surface via logEvent (TODO:
4790
+ // wire a dedicated audit channel once the realtime SSE feed is up).
4791
+ void notifyOnCommentCreated({
4792
+ commentId: result.value.id,
4793
+ body: result.value.body,
4794
+ parentId: result.value.parentId,
4795
+ documentId: result.value.documentId,
4796
+ actorId: auth.session.userId,
4797
+ actorName: null,
4798
+ }).catch(() => undefined);
4799
+ return json({ data: result.value }, 201);
4800
+ }
4801
+ catch (err) {
4802
+ return internalError(err, 'create comment');
4803
+ }
4804
+ });
4805
+ router.get('/documents/:documentId/comments', async (request, params) => {
4806
+ try {
4807
+ const auth = await requireAuth(request);
4808
+ if (auth.error)
4809
+ return auth.error;
4810
+ // Reading comments piggy-backs on the same scope as reading the
4811
+ // document. There is no comment-specific API-key scope today; we
4812
+ // re-use document-read so external integrations can fetch a thread
4813
+ // without granting them write access.
4814
+ const url = new URL(request.url);
4815
+ const includeResolved = url.searchParams.get('includeResolved') === 'true';
4816
+ const includeDeleted = url.searchParams.get('includeDeleted') === 'true';
4817
+ const pageSizeRaw = url.searchParams.get('pageSize');
4818
+ const pageSize = pageSizeRaw === null ? undefined : Number(pageSizeRaw);
4819
+ // Soft-deleted comments are admin-only — they hold moderation history
4820
+ // that should not leak via a query parameter to a regular editor.
4821
+ if (includeDeleted && !ADMIN_ROLES.has(auth.session.role)) {
4822
+ return errorResponse('includeDeleted requires admin role', 403);
4823
+ }
4824
+ const result = await listCommentsService(commentsDb(), {
4825
+ documentId: params.documentId,
4826
+ includeResolved,
4827
+ includeDeleted,
4828
+ pageSize,
4829
+ });
4830
+ if (!result.ok)
4831
+ return respondCommentsError(result.error);
4832
+ return json({ data: result.value });
4833
+ }
4834
+ catch (err) {
4835
+ return internalError(err, 'list comments');
4836
+ }
4837
+ });
4838
+ router.patch('/comments/:id', async (request, params) => {
4839
+ try {
4840
+ const auth = await requireAuth(request);
4841
+ if (auth.error)
4842
+ return auth.error;
4843
+ const body = (await request.json());
4844
+ const result = await updateCommentService(commentsDb(), params.id, { body: typeof body.body === 'string' ? body.body : '' }, { userId: auth.session.userId, isAdmin: ADMIN_ROLES.has(auth.session.role) });
4845
+ if (!result.ok)
4846
+ return respondCommentsError(result.error);
4847
+ return json({ data: result.value });
4848
+ }
4849
+ catch (err) {
4850
+ return internalError(err, 'update comment');
4851
+ }
4852
+ });
4853
+ router.post('/comments/:id/resolve', async (request, params) => {
4854
+ try {
4855
+ const auth = await requireAuth(request);
4856
+ if (auth.error)
4857
+ return auth.error;
4858
+ const result = await resolveCommentService(commentsDb(), params.id, {
4859
+ userId: auth.session.userId,
4860
+ canResolve: WRITE_ROLES.has(auth.session.role),
4861
+ });
4862
+ if (!result.ok)
4863
+ return respondCommentsError(result.error);
4864
+ await logEvent({
4865
+ event: 'comment_resolved',
4866
+ userId: auth.session.userId,
4867
+ details: { commentId: params.id },
4868
+ });
4869
+ void notifyOnCommentResolved({
4870
+ commentId: params.id,
4871
+ actorId: auth.session.userId,
4872
+ actorName: null,
4873
+ }).catch(() => undefined);
4874
+ return json({ data: result.value });
4875
+ }
4876
+ catch (err) {
4877
+ return internalError(err, 'resolve comment');
4878
+ }
4879
+ });
4880
+ router.post('/comments/:id/reopen', async (request, params) => {
4881
+ try {
4882
+ const auth = await requireAuth(request);
4883
+ if (auth.error)
4884
+ return auth.error;
4885
+ const result = await reopenCommentService(commentsDb(), params.id, {
4886
+ userId: auth.session.userId,
4887
+ canResolve: WRITE_ROLES.has(auth.session.role),
4888
+ });
4889
+ if (!result.ok)
4890
+ return respondCommentsError(result.error);
4891
+ await logEvent({
4892
+ event: 'comment_reopened',
4893
+ userId: auth.session.userId,
4894
+ details: { commentId: params.id },
4895
+ });
4896
+ return json({ data: result.value });
4897
+ }
4898
+ catch (err) {
4899
+ return internalError(err, 'reopen comment');
4900
+ }
4901
+ });
4902
+ router.delete('/comments/:id', async (request, params) => {
4903
+ try {
4904
+ const auth = await requireAuth(request);
4905
+ if (auth.error)
4906
+ return auth.error;
4907
+ const result = await deleteCommentService(commentsDb(), params.id, {
4908
+ userId: auth.session.userId,
4909
+ isAdmin: ADMIN_ROLES.has(auth.session.role),
4910
+ });
4911
+ if (!result.ok)
4912
+ return respondCommentsError(result.error);
4913
+ await logEvent({
4914
+ event: 'comment_deleted',
4915
+ userId: auth.session.userId,
4916
+ details: { commentId: params.id },
4917
+ });
4918
+ return json({ data: result.value });
4919
+ }
4920
+ catch (err) {
4921
+ return internalError(err, 'delete comment');
4922
+ }
4923
+ });
4924
+ // ---------------------------------------------------------------------------
4925
+ // Notifications — Phase 3 / slice 6
4926
+ //
4927
+ // Notifications are the read-side of the collaboration event stream:
4928
+ // when a comment lifecycle action affects another user (reply,
4929
+ // mention, resolve-of-yours) we record a row in `DocumentNotification`
4930
+ // and the bell in the admin polls / streams it.
4931
+ //
4932
+ // Routes:
4933
+ //
4934
+ // GET /notifications — list current user's feed
4935
+ // GET /notifications/unread-count — badge counter
4936
+ // POST /notifications/:id/read — mark a single notification read
4937
+ // POST /notifications/read-all — clear the bell
4938
+ //
4939
+ // Auth: every route is scoped to `auth.session.userId`; there is no
4940
+ // admin override (notifications are personal and never moderated).
4941
+ // ---------------------------------------------------------------------------
4942
+ router.get('/notifications', async (request) => {
4943
+ try {
4944
+ const auth = await requireAuth(request);
4945
+ if (auth.error)
4946
+ return auth.error;
4947
+ const url = new URL(request.url);
4948
+ const includeRead = url.searchParams.get('includeRead') === 'true';
4949
+ const pageSizeRaw = url.searchParams.get('pageSize');
4950
+ const pageSize = pageSizeRaw === null ? undefined : Number(pageSizeRaw);
4951
+ const result = await listNotificationsForUser(notificationsDb(), {
4952
+ userId: auth.session.userId,
4953
+ includeRead,
4954
+ pageSize,
4955
+ });
4956
+ if (!result.ok)
4957
+ return respondNotificationsError(result.error);
4958
+ return json({ data: result.value });
4959
+ }
4960
+ catch (err) {
4961
+ return internalError(err, 'list notifications');
4962
+ }
4963
+ });
4964
+ router.get('/notifications/unread-count', async (request) => {
4965
+ try {
4966
+ const auth = await requireAuth(request);
4967
+ if (auth.error)
4968
+ return auth.error;
4969
+ const result = await notificationsUnreadCount(notificationsDb(), auth.session.userId);
4970
+ if (!result.ok)
4971
+ return respondNotificationsError(result.error);
4972
+ return json({ data: { count: result.value } });
4973
+ }
4974
+ catch (err) {
4975
+ return internalError(err, 'notifications unread count');
4976
+ }
4977
+ });
4978
+ router.post('/notifications/:id/read', async (request, params) => {
4979
+ try {
4980
+ const auth = await requireAuth(request);
4981
+ if (auth.error)
4982
+ return auth.error;
4983
+ const result = await markNotificationRead(notificationsDb(), params.id, {
4984
+ userId: auth.session.userId,
4985
+ });
4986
+ if (!result.ok)
4987
+ return respondNotificationsError(result.error);
4988
+ return json({ data: result.value });
4989
+ }
4990
+ catch (err) {
4991
+ return internalError(err, 'mark notification read');
4992
+ }
4993
+ });
4994
+ router.post('/notifications/read-all', async (request) => {
4995
+ try {
4996
+ const auth = await requireAuth(request);
4997
+ if (auth.error)
4998
+ return auth.error;
4999
+ const result = await markAllNotificationsRead(notificationsDb(), auth.session.userId);
5000
+ if (!result.ok)
5001
+ return respondNotificationsError(result.error);
5002
+ return json({ data: result.value });
5003
+ }
5004
+ catch (err) {
5005
+ return internalError(err, 'mark all notifications read');
5006
+ }
5007
+ });
4436
5008
  // ---------------------------------------------------------------------------
4437
5009
  // Workflow routes
4438
5010
  // ---------------------------------------------------------------------------
@@ -4495,6 +5067,11 @@ export function registerCMSRoutes(router) {
4495
5067
  return errorResponse('Document not found', 404);
4496
5068
  const [versions, total] = await Promise.all([
4497
5069
  db().version.findMany({
5070
+ // See dashboard `recent docs` query for the rationale: `'join'`
5071
+ // folds the User include into one round-trip, halving the cost
5072
+ // of the versions list on a cross-region Function -> Postgres
5073
+ // path.
5074
+ relationLoadStrategy: 'join',
4498
5075
  where: { documentId: params.id },
4499
5076
  orderBy: { createdAt: 'desc' },
4500
5077
  skip,
@@ -4808,12 +5385,19 @@ export function registerCMSRoutes(router) {
4808
5385
  const scopeErr = requireGlobalScope(auth.session, params.slug);
4809
5386
  if (scopeErr)
4810
5387
  return scopeErr;
4811
- const ctx = buildActionContext(auth.session, db());
4812
- const global = await getGlobal(params.slug, ctx);
4813
- if (!global) {
4814
- return errorResponse('Global not found', 404);
4815
- }
4816
- return json({ data: global });
5388
+ return cachedRead(request, {
5389
+ cache: getActuateConfig()?.cache,
5390
+ tags: [ACTUATE_TAG_ROOT, actuateTag('global', params.slug)],
5391
+ wrap: jsonWithCache,
5392
+ build: async () => {
5393
+ const ctx = buildActionContext(auth.session, db());
5394
+ const global = await getGlobal(params.slug, ctx);
5395
+ if (!global) {
5396
+ return errorResponse('Global not found', 404);
5397
+ }
5398
+ return { data: global };
5399
+ },
5400
+ });
4817
5401
  }
4818
5402
  catch (err) {
4819
5403
  return internalError(err);