@actuate-media/cms-core 0.21.0 → 0.22.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 (105) 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/comments-routes.test.d.ts +2 -0
  8. package/dist/__tests__/api/comments-routes.test.d.ts.map +1 -0
  9. package/dist/__tests__/api/comments-routes.test.js +266 -0
  10. package/dist/__tests__/api/comments-routes.test.js.map +1 -0
  11. package/dist/__tests__/api/notifications-routes.test.d.ts +2 -0
  12. package/dist/__tests__/api/notifications-routes.test.d.ts.map +1 -0
  13. package/dist/__tests__/api/notifications-routes.test.js +225 -0
  14. package/dist/__tests__/api/notifications-routes.test.js.map +1 -0
  15. package/dist/__tests__/api/preview-and-scheduling.test.js +53 -0
  16. package/dist/__tests__/api/preview-and-scheduling.test.js.map +1 -1
  17. package/dist/__tests__/fields/relations.test.js +62 -8
  18. package/dist/__tests__/fields/relations.test.js.map +1 -1
  19. package/dist/__tests__/preview-event-bus.test.d.ts +2 -0
  20. package/dist/__tests__/preview-event-bus.test.d.ts.map +1 -0
  21. package/dist/__tests__/preview-event-bus.test.js +89 -0
  22. package/dist/__tests__/preview-event-bus.test.js.map +1 -0
  23. package/dist/__tests__/preview-sse-stream.test.d.ts +2 -0
  24. package/dist/__tests__/preview-sse-stream.test.d.ts.map +1 -0
  25. package/dist/__tests__/preview-sse-stream.test.js +117 -0
  26. package/dist/__tests__/preview-sse-stream.test.js.map +1 -0
  27. package/dist/__tests__/realtime/comments.test.d.ts +2 -0
  28. package/dist/__tests__/realtime/comments.test.d.ts.map +1 -0
  29. package/dist/__tests__/realtime/comments.test.js +508 -0
  30. package/dist/__tests__/realtime/comments.test.js.map +1 -0
  31. package/dist/__tests__/realtime/gateway-ws-integration.test.d.ts +2 -0
  32. package/dist/__tests__/realtime/gateway-ws-integration.test.d.ts.map +1 -0
  33. package/dist/__tests__/realtime/gateway-ws-integration.test.js +170 -0
  34. package/dist/__tests__/realtime/gateway-ws-integration.test.js.map +1 -0
  35. package/dist/__tests__/realtime/gateway.test.d.ts +2 -0
  36. package/dist/__tests__/realtime/gateway.test.d.ts.map +1 -0
  37. package/dist/__tests__/realtime/gateway.test.js +402 -0
  38. package/dist/__tests__/realtime/gateway.test.js.map +1 -0
  39. package/dist/__tests__/realtime/notifications.test.d.ts +2 -0
  40. package/dist/__tests__/realtime/notifications.test.d.ts.map +1 -0
  41. package/dist/__tests__/realtime/notifications.test.js +317 -0
  42. package/dist/__tests__/realtime/notifications.test.js.map +1 -0
  43. package/dist/__tests__/realtime/persistence.test.d.ts +2 -0
  44. package/dist/__tests__/realtime/persistence.test.d.ts.map +1 -0
  45. package/dist/__tests__/realtime/persistence.test.js +124 -0
  46. package/dist/__tests__/realtime/persistence.test.js.map +1 -0
  47. package/dist/__tests__/schema-preview-features.test.d.ts +2 -0
  48. package/dist/__tests__/schema-preview-features.test.d.ts.map +1 -0
  49. package/dist/__tests__/schema-preview-features.test.js +42 -0
  50. package/dist/__tests__/schema-preview-features.test.js.map +1 -0
  51. package/dist/actions.d.ts.map +1 -1
  52. package/dist/actions.js +71 -26
  53. package/dist/actions.js.map +1 -1
  54. package/dist/api/handlers.d.ts.map +1 -1
  55. package/dist/api/handlers.js +734 -188
  56. package/dist/api/handlers.js.map +1 -1
  57. package/dist/cache/__tests__/http.test.d.ts +2 -0
  58. package/dist/cache/__tests__/http.test.d.ts.map +1 -0
  59. package/dist/cache/__tests__/http.test.js +285 -0
  60. package/dist/cache/__tests__/http.test.js.map +1 -0
  61. package/dist/cache/http.d.ts +119 -0
  62. package/dist/cache/http.d.ts.map +1 -0
  63. package/dist/cache/http.js +188 -0
  64. package/dist/cache/http.js.map +1 -0
  65. package/dist/config/types.d.ts +92 -0
  66. package/dist/config/types.d.ts.map +1 -1
  67. package/dist/fields/relations.d.ts.map +1 -1
  68. package/dist/fields/relations.js +43 -11
  69. package/dist/fields/relations.js.map +1 -1
  70. package/dist/index.d.ts +5 -0
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +2 -0
  73. package/dist/index.js.map +1 -1
  74. package/dist/page-builder/schema.d.ts +2 -2
  75. package/dist/preview/event-bus.d.ts +39 -0
  76. package/dist/preview/event-bus.d.ts.map +1 -0
  77. package/dist/preview/event-bus.js +73 -0
  78. package/dist/preview/event-bus.js.map +1 -0
  79. package/dist/preview/sse-stream.d.ts +50 -0
  80. package/dist/preview/sse-stream.d.ts.map +1 -0
  81. package/dist/preview/sse-stream.js +120 -0
  82. package/dist/preview/sse-stream.js.map +1 -0
  83. package/dist/realtime/comments.d.ts +217 -0
  84. package/dist/realtime/comments.d.ts.map +1 -0
  85. package/dist/realtime/comments.js +282 -0
  86. package/dist/realtime/comments.js.map +1 -0
  87. package/dist/realtime/gateway.d.ts +107 -0
  88. package/dist/realtime/gateway.d.ts.map +1 -0
  89. package/dist/realtime/gateway.js +215 -0
  90. package/dist/realtime/gateway.js.map +1 -0
  91. package/dist/realtime/index.d.ts +18 -0
  92. package/dist/realtime/index.d.ts.map +1 -0
  93. package/dist/realtime/index.js +18 -0
  94. package/dist/realtime/index.js.map +1 -0
  95. package/dist/realtime/notifications.d.ts +209 -0
  96. package/dist/realtime/notifications.d.ts.map +1 -0
  97. package/dist/realtime/notifications.js +209 -0
  98. package/dist/realtime/notifications.js.map +1 -0
  99. package/dist/realtime/persistence.d.ts +72 -0
  100. package/dist/realtime/persistence.d.ts.map +1 -0
  101. package/dist/realtime/persistence.js +53 -0
  102. package/dist/realtime/persistence.js.map +1 -0
  103. package/package.json +13 -4
  104. package/prisma/cms-schema.prisma +135 -4
  105. 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 {
@@ -1451,33 +1509,40 @@ export function registerCMSRoutes(router) {
1451
1509
  const scopeErr = requireCollectionScope(auth.session, params.slug, 'read');
1452
1510
  if (scopeErr)
1453
1511
  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
- };
1512
+ return cachedRead(request, {
1513
+ cache: getActuateConfig()?.cache,
1514
+ tags: [ACTUATE_TAG_ROOT, actuateTag('collection', params.slug)],
1515
+ wrap: jsonWithCache,
1516
+ build: async () => {
1517
+ const url = new URL(request.url);
1518
+ const ctx = buildActionContext(auth.session, db(), url.searchParams.get('locale') ?? undefined);
1519
+ const result = await listDocuments({
1520
+ collection: params.slug,
1521
+ page: Number(url.searchParams.get('page')) || 1,
1522
+ pageSize: clampPageSize(url.searchParams.get('pageSize')),
1523
+ sort: url.searchParams.get('sort') ?? undefined,
1524
+ order: url.searchParams.get('order') ?? undefined,
1525
+ status: url.searchParams.get('status') ?? undefined,
1526
+ locale: url.searchParams.get('locale') ?? undefined,
1527
+ folderId: url.searchParams.get('folderId') ?? undefined,
1528
+ }, ctx);
1529
+ const collectionConfig = getActuateConfig()?.collections?.[params.slug];
1530
+ const fields = collectionConfig?.fields;
1531
+ if (fields && result.docs.length > 0) {
1532
+ const user = { id: auth.session.userId, role: auth.session.role };
1533
+ result.docs = await Promise.all(result.docs.map(async (doc) => {
1534
+ if (doc.data && typeof doc.data === 'object') {
1535
+ return {
1536
+ ...doc,
1537
+ data: await applyFieldAccess('read', fields, doc.data, user),
1538
+ };
1539
+ }
1540
+ return doc;
1541
+ }));
1476
1542
  }
1477
- return doc;
1478
- }));
1479
- }
1480
- return json({ data: result });
1543
+ return { data: result };
1544
+ },
1545
+ });
1481
1546
  }
1482
1547
  catch (err) {
1483
1548
  return internalError(err);
@@ -1491,22 +1556,36 @@ export function registerCMSRoutes(router) {
1491
1556
  const scopeErr = requireCollectionScope(auth.session, params.slug, 'read');
1492
1557
  if (scopeErr)
1493
1558
  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 });
1559
+ return cachedRead(request, {
1560
+ cache: getActuateConfig()?.cache,
1561
+ tags: [
1562
+ ACTUATE_TAG_ROOT,
1563
+ actuateTag('collection', params.slug),
1564
+ actuateTag('doc', params.id),
1565
+ ],
1566
+ wrap: jsonWithCache,
1567
+ build: async () => {
1568
+ const ctx = buildActionContext(auth.session, db());
1569
+ const doc = await getDocument(params.slug, params.id, ctx);
1570
+ if (!doc) {
1571
+ // Returning a Response short-circuits the cache layer we
1572
+ // never cache 404s. The next request gets a fresh DB lookup
1573
+ // so a doc that's restored from soft-delete is visible
1574
+ // immediately.
1575
+ return errorResponse('Document not found', 404);
1576
+ }
1577
+ // `getDocument` already lifts `_layout` / `_pageSettings` to
1578
+ // the top-level page builder envelope and strips internal keys
1579
+ // from `data`. Field-level access has been applied as well.
1580
+ //
1581
+ // Opt-in relation population (?populate=author,relatedPosts |
1582
+ // *) replaces stored IDs with reference summaries. The
1583
+ // `?populate=` param is part of the cache key (URL-derived),
1584
+ // so populated and non-populated reads cache independently.
1585
+ const populated = await maybePopulate(request, params.slug, doc, db());
1586
+ return { data: populated };
1587
+ },
1588
+ });
1510
1589
  }
1511
1590
  catch (err) {
1512
1591
  return internalError(err);
@@ -1550,6 +1629,13 @@ export function registerCMSRoutes(router) {
1550
1629
  userId: auth.session.userId,
1551
1630
  details: { collection: params.slug, documentId: params.id },
1552
1631
  });
1632
+ // Push to any open live-preview SSE streams. No-op when no admin is
1633
+ // watching the document; the bus drops the call cheaply.
1634
+ notifyPreviewUpdate(params.slug, params.id, {
1635
+ values: doc.data,
1636
+ status: doc.status,
1637
+ updatedAt: doc.updatedAt?.toISOString?.(),
1638
+ });
1553
1639
  return json({ data: doc });
1554
1640
  }
1555
1641
  catch (err) {
@@ -2395,23 +2481,24 @@ export function registerCMSRoutes(router) {
2395
2481
  recentDocuments: [],
2396
2482
  };
2397
2483
  async function safeGroupBy(model, by, where) {
2484
+ if (!model || typeof model !== 'object')
2485
+ return [];
2486
+ let groupByFn;
2398
2487
  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 } });
2488
+ groupByFn = model.groupBy;
2411
2489
  }
2412
2490
  catch {
2413
2491
  return [];
2414
2492
  }
2493
+ if (typeof groupByFn !== 'function')
2494
+ return [];
2495
+ try {
2496
+ return await groupByFn.call(model, { by, where: where ?? {}, _count: { _all: true } });
2497
+ }
2498
+ catch (err) {
2499
+ logSafeQueryFailure('safeGroupBy', err);
2500
+ return [];
2501
+ }
2415
2502
  }
2416
2503
  function computeLightSeoScore(data) {
2417
2504
  let score = 0;
@@ -2444,6 +2531,12 @@ export function registerCMSRoutes(router) {
2444
2531
  safeCount(d.media),
2445
2532
  safeCount(d.user),
2446
2533
  safeFindMany(d.document, {
2534
+ // Prisma 7's `'join'` strategy folds the User relation into a
2535
+ // single LATERAL SQL query so the dashboard read costs one
2536
+ // round-trip instead of two. On the production Vercel Function
2537
+ // -> Prisma Postgres path that saves ~80 ms (one full network
2538
+ // RTT) on every dashboard load.
2539
+ relationLoadStrategy: 'join',
2447
2540
  where: { deletedAt: null },
2448
2541
  orderBy: { updatedAt: 'desc' },
2449
2542
  take: 20,
@@ -4044,6 +4137,12 @@ export function registerCMSRoutes(router) {
4044
4137
  // verify the URL actually points at the document the token was
4045
4138
  // minted for; this prevents using one token to peek at unrelated
4046
4139
  // drafts in the same collection.
4140
+ //
4141
+ // Validation happens BEFORE the runtime-cache lookup so a preview
4142
+ // session can never read from (or write to) the shared cache. The
4143
+ // small price is that every `/resolve?preview=` call runs a JWT
4144
+ // verification even on what would otherwise be a cache hit —
4145
+ // worth it; the alternative is a cache poisoned with draft data.
4047
4146
  const previewToken = url.searchParams.get('preview');
4048
4147
  let previewSession = null;
4049
4148
  if (previewToken) {
@@ -4063,112 +4162,135 @@ export function registerCMSRoutes(router) {
4063
4162
  }
4064
4163
  }
4065
4164
  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(),
4165
+ // Heavy compute (doc lookup → populate → meta composition) lives in
4166
+ // a closure so the optional runtime-cache layer can short-circuit
4167
+ // it on a hit. The cache layer is bypassed entirely when a preview
4168
+ // session is active a draft must never enter the shared cache.
4169
+ const buildResolveBody = async () => {
4170
+ const slugFilter = {
4171
+ OR: isRootPath
4172
+ ? [
4173
+ { data: { path: ['slug'], equals: 'home' } },
4174
+ { data: { path: ['slug'], equals: 'index' } },
4175
+ { slug: 'home' },
4176
+ { slug: 'index' },
4177
+ ]
4178
+ : [{ data: { path: ['slug'], equals: docSlug } }, { slug: docSlug }],
4179
+ };
4180
+ const doc = previewSession
4181
+ ? await db().document.findFirst({
4182
+ where: {
4183
+ collection: matchedCollection,
4184
+ deletedAt: null,
4185
+ ...slugFilter,
4169
4186
  },
4187
+ })
4188
+ : await db().document.findFirst({
4189
+ where: {
4190
+ collection: matchedCollection,
4191
+ deletedAt: null,
4192
+ status: 'PUBLISHED',
4193
+ ...slugFilter,
4194
+ },
4195
+ });
4196
+ if (!doc) {
4197
+ return errorResponse('Document not found', 404);
4198
+ }
4199
+ // Enforce the token ↔ document binding. A leaked or copied token can
4200
+ // only unlock the exact document it was issued for.
4201
+ if (previewSession &&
4202
+ (previewSession.documentId !== doc.id || previewSession.collection !== matchedCollection)) {
4203
+ return errorResponse('Preview token does not match this URL', 403);
4204
+ }
4205
+ let docData = doc.data && typeof doc.data === 'object' ? doc.data : {};
4206
+ // Opt-in: ?populate=author,relatedPosts (or *) — expand relation IDs
4207
+ // into `{ id, title, slug, status, collection }` summaries so the
4208
+ // frontend renders authors / related case studies without a second
4209
+ // request per reference. `resolve` is the highest-leverage place to
4210
+ // do this because it's what marketing pages call from SSR.
4211
+ const populateOpts = parsePopulateParam(new URL(request.url).searchParams.get('populate'));
4212
+ if (populateOpts) {
4213
+ const fields = getActuateConfig()?.collections?.[matchedCollection]?.fields;
4214
+ if (fields) {
4215
+ try {
4216
+ docData = await populateRelations(fields, docData, buildRelationLookup(db()), populateOpts);
4217
+ }
4218
+ catch (err) {
4219
+ console.error('[actuate][api] resolve populate failed:', err);
4220
+ }
4170
4221
  }
4171
- : {}),
4222
+ }
4223
+ const layout = await resolveLayout(pathParam, docData, matchedCollection);
4224
+ const { _layout: _omit, ...cleanData } = docData;
4225
+ // Compose page meta + JSON-LD up front so client renderers (Next.js
4226
+ // generateMetadata, plain SSR, MCP agents) don't have to re-derive
4227
+ // schema, OG tags, and canonical URLs from raw doc data. Reads the
4228
+ // collection's SEO config and the site-wide SEO defaults — with
4229
+ // DB overrides from /seo/config applied on top of the static config.
4230
+ const cfg = await loadEffectiveConfig();
4231
+ const collectionDef = cfg?.collections?.[matchedCollection] ?? null;
4232
+ const { composePageMeta } = await import('../seo/page-meta.js');
4233
+ const composed = composePageMeta({
4234
+ doc: {
4235
+ id: doc.id,
4236
+ collection: doc.collection,
4237
+ slug: doc.slug ?? cleanData.slug ?? null,
4238
+ data: cleanData,
4239
+ publishedAt: doc.publishedAt,
4240
+ updatedAt: doc.updatedAt,
4241
+ structuredData: doc.structuredData ?? null,
4242
+ pageSettings: cleanData.pageSettings ?? null,
4243
+ },
4244
+ collection: collectionDef,
4245
+ config: cfg ?? null,
4246
+ siteUrl: siteUrlFromRequest(request, cfg),
4247
+ });
4248
+ return {
4249
+ data: {
4250
+ id: doc.id,
4251
+ collection: doc.collection,
4252
+ data: cleanData,
4253
+ status: doc.status,
4254
+ publishedAt: doc.publishedAt,
4255
+ structuredData: composed.jsonLd ?? doc.structuredData,
4256
+ },
4257
+ meta: {
4258
+ title: composed.title,
4259
+ description: composed.description,
4260
+ canonical: composed.canonical,
4261
+ url: composed.url,
4262
+ tags: composed.meta,
4263
+ html: composed.metaHtml,
4264
+ },
4265
+ jsonLd: composed.jsonLd,
4266
+ jsonLdHtml: composed.jsonLdHtml,
4267
+ ...(Object.keys(layout).length > 0 ? { layout } : {}),
4268
+ ...(previewSession
4269
+ ? {
4270
+ preview: {
4271
+ active: true,
4272
+ expiresAt: previewSession.expiresAt.toISOString(),
4273
+ },
4274
+ }
4275
+ : {}),
4276
+ };
4277
+ };
4278
+ // Preview-token requests bypass the cache (CDN + Runtime) so a
4279
+ // leaked cached preview can never show up for anonymous traffic.
4280
+ // Anonymous reads flow through cachedRead — coarse tags (the
4281
+ // wholesale `actuate` tag + a path tag) so every write drains
4282
+ // every cached resolve entry. Per-doc tagging would require
4283
+ // resolving the doc id BEFORE the cache lookup, which defeats
4284
+ // the purpose of caching at all.
4285
+ if (previewSession) {
4286
+ const body = await buildResolveBody();
4287
+ return body instanceof Response ? body : json(body);
4288
+ }
4289
+ return cachedRead(request, {
4290
+ cache: getActuateConfig()?.cache,
4291
+ tags: [ACTUATE_TAG_ROOT, actuateTag('path', pathParam)],
4292
+ wrap: jsonWithCache,
4293
+ build: buildResolveBody,
4172
4294
  });
4173
4295
  }
4174
4296
  catch (err) {
@@ -4433,6 +4555,418 @@ export function registerCMSRoutes(router) {
4433
4555
  return internalError(err, 'fetch preview data');
4434
4556
  }
4435
4557
  });
4558
+ /**
4559
+ * Server-Sent Events stream for live preview. The admin's `LivePreview`
4560
+ * panel opens an `EventSource('/api/cms/preview/stream?token=...')` and
4561
+ * relays every `event: update` frame to the previewed iframe through
4562
+ * `postMessage`. Token-based auth (instead of cookies) because
4563
+ * `EventSource` cannot send arbitrary headers.
4564
+ *
4565
+ * Emits:
4566
+ * - `event: ready` once on connection (handshake).
4567
+ * - `event: update` whenever the underlying draft document is updated
4568
+ * (see `PUT /collections/:slug/:id` for the emit site).
4569
+ * - `: heartbeat\n\n` keepalive comments every 25 s so proxies don't
4570
+ * reap idle connections.
4571
+ *
4572
+ * Closes cleanly after 4 minutes so platform idle caps don't terminate
4573
+ * the connection mid-event; EventSource will auto-reconnect.
4574
+ */
4575
+ router.get('/preview/stream', async (request) => {
4576
+ try {
4577
+ const url = new URL(request.url);
4578
+ const token = url.searchParams.get('token');
4579
+ if (!token)
4580
+ return errorResponse('Preview token required', 401);
4581
+ const preview = createPreviewAdapter(getSessionSecret(), db());
4582
+ const session = await preview.validatePreviewToken(token);
4583
+ if (!session)
4584
+ return errorResponse('Invalid or expired preview token', 401);
4585
+ return createPreviewStreamResponse(session);
4586
+ }
4587
+ catch (err) {
4588
+ return internalError(err, 'open preview stream');
4589
+ }
4590
+ });
4591
+ // ---------------------------------------------------------------------------
4592
+ // Comments — Phase 3 / slice 4
4593
+ //
4594
+ // Threaded, anchored discussion attached to documents. The Yjs gateway
4595
+ // already lives on `/realtime/sync`; comments are *not* sent over that
4596
+ // wire — they're plain REST so plugins, mobile clients, and notification
4597
+ // workers can consume them without speaking the CRDT protocol.
4598
+ //
4599
+ // Routes (all rewritten paths, no `/api/cms` prefix):
4600
+ // POST /documents/:documentId/comments — create top-level / reply
4601
+ // GET /documents/:documentId/comments — list (filters: resolved, deleted, pageSize)
4602
+ // PATCH /comments/:id — edit body (author / admin)
4603
+ // POST /comments/:id/resolve — resolve thread (write-role / author)
4604
+ // POST /comments/:id/reopen — re-open a resolved thread
4605
+ // DELETE /comments/:id — soft-delete (author / admin)
4606
+ //
4607
+ // The service module in `realtime/comments.ts` owns validation +
4608
+ // permissions; these handlers are thin envelopes that map auth → service
4609
+ // call → JSON response.
4610
+ // ---------------------------------------------------------------------------
4611
+ function mapCommentsErrorStatus(error) {
4612
+ switch (error.code) {
4613
+ case 'validation':
4614
+ return 400;
4615
+ case 'not_found':
4616
+ return 404;
4617
+ case 'forbidden':
4618
+ return 403;
4619
+ case 'conflict':
4620
+ return 409;
4621
+ }
4622
+ }
4623
+ function respondCommentsError(error) {
4624
+ return errorResponse(error.message, mapCommentsErrorStatus(error));
4625
+ }
4626
+ function commentsDb() {
4627
+ return db();
4628
+ }
4629
+ function notificationsDb() {
4630
+ return db();
4631
+ }
4632
+ function mapNotificationsErrorStatus(error) {
4633
+ if (error.code === 'validation')
4634
+ return 400;
4635
+ if (error.code === 'forbidden')
4636
+ return 403;
4637
+ if (error.code === 'not_found')
4638
+ return 404;
4639
+ return 500;
4640
+ }
4641
+ function respondNotificationsError(error) {
4642
+ return errorResponse(error.message, mapNotificationsErrorStatus(error));
4643
+ }
4644
+ /**
4645
+ * Lookup helper used by the notification triggers below. We need the
4646
+ * comment's `userId` (and, for replies, the *parent* comment's userId)
4647
+ * so we can decide who to notify. The lookup is best-effort: if the
4648
+ * row is missing (e.g. the comment was deleted between create and
4649
+ * trigger), we silently skip the notification.
4650
+ */
4651
+ async function findCommentRecipient(commentId) {
4652
+ try {
4653
+ const client = db();
4654
+ if (!client.documentComment)
4655
+ return null;
4656
+ return await client.documentComment.findUnique({ where: { id: commentId } });
4657
+ }
4658
+ catch {
4659
+ return null;
4660
+ }
4661
+ }
4662
+ /**
4663
+ * Issue notifications for a new comment. Two paths:
4664
+ *
4665
+ * - **Reply** — the actor replied to someone else's thread → notify
4666
+ * the root comment's author with kind `comment_reply`.
4667
+ * - **Mentions** — any `@<userId>` token in the body → notify each
4668
+ * mentioned user (other than the actor) with kind `comment_mention`.
4669
+ */
4670
+ async function notifyOnCommentCreated(opts) {
4671
+ const ndb = notificationsDb();
4672
+ if (opts.parentId) {
4673
+ const root = await findCommentRecipient(opts.parentId);
4674
+ if (root && root.userId && root.userId !== opts.actorId) {
4675
+ await createNotification(ndb, {
4676
+ userId: root.userId,
4677
+ documentId: opts.documentId,
4678
+ commentId: opts.commentId,
4679
+ payload: {
4680
+ kind: 'comment_reply',
4681
+ actorId: opts.actorId,
4682
+ actorName: opts.actorName,
4683
+ excerpt: excerptForNotification(opts.body),
4684
+ },
4685
+ });
4686
+ }
4687
+ }
4688
+ for (const userId of extractMentionedUserIds(opts.body)) {
4689
+ if (userId === opts.actorId)
4690
+ continue;
4691
+ await createNotification(ndb, {
4692
+ userId,
4693
+ documentId: opts.documentId,
4694
+ commentId: opts.commentId,
4695
+ payload: {
4696
+ kind: 'comment_mention',
4697
+ actorId: opts.actorId,
4698
+ actorName: opts.actorName,
4699
+ excerpt: excerptForNotification(opts.body),
4700
+ },
4701
+ });
4702
+ }
4703
+ }
4704
+ /** Notify the comment's author when someone else resolves their thread. */
4705
+ async function notifyOnCommentResolved(opts) {
4706
+ const comment = await findCommentRecipient(opts.commentId);
4707
+ if (!comment || !comment.userId || comment.userId === opts.actorId)
4708
+ return;
4709
+ await createNotification(notificationsDb(), {
4710
+ userId: comment.userId,
4711
+ commentId: opts.commentId,
4712
+ payload: {
4713
+ kind: 'comment_resolved',
4714
+ actorId: opts.actorId,
4715
+ actorName: opts.actorName,
4716
+ rootExcerpt: excerptForNotification(comment.body),
4717
+ },
4718
+ });
4719
+ }
4720
+ router.post('/documents/:documentId/comments', async (request, params) => {
4721
+ try {
4722
+ const auth = await requireAuth(request);
4723
+ if (auth.error)
4724
+ return auth.error;
4725
+ // Posting a comment requires at least write access; comments mutate
4726
+ // collaborative state and we don't want anonymous read-only tokens
4727
+ // (API keys without write scope) leaving discussion behind.
4728
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4729
+ if (roleErr)
4730
+ return roleErr;
4731
+ const body = (await request.json());
4732
+ const result = await createCommentService(commentsDb(), {
4733
+ documentId: params.documentId,
4734
+ userId: auth.session.userId,
4735
+ body: typeof body.body === 'string' ? body.body : '',
4736
+ anchor: body.anchor,
4737
+ parentId: typeof body.parentId === 'string' ? body.parentId : null,
4738
+ });
4739
+ if (!result.ok)
4740
+ return respondCommentsError(result.error);
4741
+ await logEvent({
4742
+ event: 'comment_created',
4743
+ userId: auth.session.userId,
4744
+ details: {
4745
+ documentId: params.documentId,
4746
+ commentId: result.value.id,
4747
+ parentId: result.value.parentId,
4748
+ },
4749
+ });
4750
+ // Fire-and-forget — a notification failure should never block the
4751
+ // comment from being saved. Errors surface via logEvent (TODO:
4752
+ // wire a dedicated audit channel once the realtime SSE feed is up).
4753
+ void notifyOnCommentCreated({
4754
+ commentId: result.value.id,
4755
+ body: result.value.body,
4756
+ parentId: result.value.parentId,
4757
+ documentId: result.value.documentId,
4758
+ actorId: auth.session.userId,
4759
+ actorName: null,
4760
+ }).catch(() => undefined);
4761
+ return json({ data: result.value }, 201);
4762
+ }
4763
+ catch (err) {
4764
+ return internalError(err, 'create comment');
4765
+ }
4766
+ });
4767
+ router.get('/documents/:documentId/comments', async (request, params) => {
4768
+ try {
4769
+ const auth = await requireAuth(request);
4770
+ if (auth.error)
4771
+ return auth.error;
4772
+ // Reading comments piggy-backs on the same scope as reading the
4773
+ // document. There is no comment-specific API-key scope today; we
4774
+ // re-use document-read so external integrations can fetch a thread
4775
+ // without granting them write access.
4776
+ const url = new URL(request.url);
4777
+ const includeResolved = url.searchParams.get('includeResolved') === 'true';
4778
+ const includeDeleted = url.searchParams.get('includeDeleted') === 'true';
4779
+ const pageSizeRaw = url.searchParams.get('pageSize');
4780
+ const pageSize = pageSizeRaw === null ? undefined : Number(pageSizeRaw);
4781
+ // Soft-deleted comments are admin-only — they hold moderation history
4782
+ // that should not leak via a query parameter to a regular editor.
4783
+ if (includeDeleted && !ADMIN_ROLES.has(auth.session.role)) {
4784
+ return errorResponse('includeDeleted requires admin role', 403);
4785
+ }
4786
+ const result = await listCommentsService(commentsDb(), {
4787
+ documentId: params.documentId,
4788
+ includeResolved,
4789
+ includeDeleted,
4790
+ pageSize,
4791
+ });
4792
+ if (!result.ok)
4793
+ return respondCommentsError(result.error);
4794
+ return json({ data: result.value });
4795
+ }
4796
+ catch (err) {
4797
+ return internalError(err, 'list comments');
4798
+ }
4799
+ });
4800
+ router.patch('/comments/:id', async (request, params) => {
4801
+ try {
4802
+ const auth = await requireAuth(request);
4803
+ if (auth.error)
4804
+ return auth.error;
4805
+ const body = (await request.json());
4806
+ 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) });
4807
+ if (!result.ok)
4808
+ return respondCommentsError(result.error);
4809
+ return json({ data: result.value });
4810
+ }
4811
+ catch (err) {
4812
+ return internalError(err, 'update comment');
4813
+ }
4814
+ });
4815
+ router.post('/comments/:id/resolve', async (request, params) => {
4816
+ try {
4817
+ const auth = await requireAuth(request);
4818
+ if (auth.error)
4819
+ return auth.error;
4820
+ const result = await resolveCommentService(commentsDb(), params.id, {
4821
+ userId: auth.session.userId,
4822
+ canResolve: WRITE_ROLES.has(auth.session.role),
4823
+ });
4824
+ if (!result.ok)
4825
+ return respondCommentsError(result.error);
4826
+ await logEvent({
4827
+ event: 'comment_resolved',
4828
+ userId: auth.session.userId,
4829
+ details: { commentId: params.id },
4830
+ });
4831
+ void notifyOnCommentResolved({
4832
+ commentId: params.id,
4833
+ actorId: auth.session.userId,
4834
+ actorName: null,
4835
+ }).catch(() => undefined);
4836
+ return json({ data: result.value });
4837
+ }
4838
+ catch (err) {
4839
+ return internalError(err, 'resolve comment');
4840
+ }
4841
+ });
4842
+ router.post('/comments/:id/reopen', async (request, params) => {
4843
+ try {
4844
+ const auth = await requireAuth(request);
4845
+ if (auth.error)
4846
+ return auth.error;
4847
+ const result = await reopenCommentService(commentsDb(), params.id, {
4848
+ userId: auth.session.userId,
4849
+ canResolve: WRITE_ROLES.has(auth.session.role),
4850
+ });
4851
+ if (!result.ok)
4852
+ return respondCommentsError(result.error);
4853
+ await logEvent({
4854
+ event: 'comment_reopened',
4855
+ userId: auth.session.userId,
4856
+ details: { commentId: params.id },
4857
+ });
4858
+ return json({ data: result.value });
4859
+ }
4860
+ catch (err) {
4861
+ return internalError(err, 'reopen comment');
4862
+ }
4863
+ });
4864
+ router.delete('/comments/:id', async (request, params) => {
4865
+ try {
4866
+ const auth = await requireAuth(request);
4867
+ if (auth.error)
4868
+ return auth.error;
4869
+ const result = await deleteCommentService(commentsDb(), params.id, {
4870
+ userId: auth.session.userId,
4871
+ isAdmin: ADMIN_ROLES.has(auth.session.role),
4872
+ });
4873
+ if (!result.ok)
4874
+ return respondCommentsError(result.error);
4875
+ await logEvent({
4876
+ event: 'comment_deleted',
4877
+ userId: auth.session.userId,
4878
+ details: { commentId: params.id },
4879
+ });
4880
+ return json({ data: result.value });
4881
+ }
4882
+ catch (err) {
4883
+ return internalError(err, 'delete comment');
4884
+ }
4885
+ });
4886
+ // ---------------------------------------------------------------------------
4887
+ // Notifications — Phase 3 / slice 6
4888
+ //
4889
+ // Notifications are the read-side of the collaboration event stream:
4890
+ // when a comment lifecycle action affects another user (reply,
4891
+ // mention, resolve-of-yours) we record a row in `DocumentNotification`
4892
+ // and the bell in the admin polls / streams it.
4893
+ //
4894
+ // Routes:
4895
+ //
4896
+ // GET /notifications — list current user's feed
4897
+ // GET /notifications/unread-count — badge counter
4898
+ // POST /notifications/:id/read — mark a single notification read
4899
+ // POST /notifications/read-all — clear the bell
4900
+ //
4901
+ // Auth: every route is scoped to `auth.session.userId`; there is no
4902
+ // admin override (notifications are personal and never moderated).
4903
+ // ---------------------------------------------------------------------------
4904
+ router.get('/notifications', async (request) => {
4905
+ try {
4906
+ const auth = await requireAuth(request);
4907
+ if (auth.error)
4908
+ return auth.error;
4909
+ const url = new URL(request.url);
4910
+ const includeRead = url.searchParams.get('includeRead') === 'true';
4911
+ const pageSizeRaw = url.searchParams.get('pageSize');
4912
+ const pageSize = pageSizeRaw === null ? undefined : Number(pageSizeRaw);
4913
+ const result = await listNotificationsForUser(notificationsDb(), {
4914
+ userId: auth.session.userId,
4915
+ includeRead,
4916
+ pageSize,
4917
+ });
4918
+ if (!result.ok)
4919
+ return respondNotificationsError(result.error);
4920
+ return json({ data: result.value });
4921
+ }
4922
+ catch (err) {
4923
+ return internalError(err, 'list notifications');
4924
+ }
4925
+ });
4926
+ router.get('/notifications/unread-count', async (request) => {
4927
+ try {
4928
+ const auth = await requireAuth(request);
4929
+ if (auth.error)
4930
+ return auth.error;
4931
+ const result = await notificationsUnreadCount(notificationsDb(), auth.session.userId);
4932
+ if (!result.ok)
4933
+ return respondNotificationsError(result.error);
4934
+ return json({ data: { count: result.value } });
4935
+ }
4936
+ catch (err) {
4937
+ return internalError(err, 'notifications unread count');
4938
+ }
4939
+ });
4940
+ router.post('/notifications/:id/read', async (request, params) => {
4941
+ try {
4942
+ const auth = await requireAuth(request);
4943
+ if (auth.error)
4944
+ return auth.error;
4945
+ const result = await markNotificationRead(notificationsDb(), params.id, {
4946
+ userId: auth.session.userId,
4947
+ });
4948
+ if (!result.ok)
4949
+ return respondNotificationsError(result.error);
4950
+ return json({ data: result.value });
4951
+ }
4952
+ catch (err) {
4953
+ return internalError(err, 'mark notification read');
4954
+ }
4955
+ });
4956
+ router.post('/notifications/read-all', async (request) => {
4957
+ try {
4958
+ const auth = await requireAuth(request);
4959
+ if (auth.error)
4960
+ return auth.error;
4961
+ const result = await markAllNotificationsRead(notificationsDb(), auth.session.userId);
4962
+ if (!result.ok)
4963
+ return respondNotificationsError(result.error);
4964
+ return json({ data: result.value });
4965
+ }
4966
+ catch (err) {
4967
+ return internalError(err, 'mark all notifications read');
4968
+ }
4969
+ });
4436
4970
  // ---------------------------------------------------------------------------
4437
4971
  // Workflow routes
4438
4972
  // ---------------------------------------------------------------------------
@@ -4495,6 +5029,11 @@ export function registerCMSRoutes(router) {
4495
5029
  return errorResponse('Document not found', 404);
4496
5030
  const [versions, total] = await Promise.all([
4497
5031
  db().version.findMany({
5032
+ // See dashboard `recent docs` query for the rationale: `'join'`
5033
+ // folds the User include into one round-trip, halving the cost
5034
+ // of the versions list on a cross-region Function -> Postgres
5035
+ // path.
5036
+ relationLoadStrategy: 'join',
4498
5037
  where: { documentId: params.id },
4499
5038
  orderBy: { createdAt: 'desc' },
4500
5039
  skip,
@@ -4808,12 +5347,19 @@ export function registerCMSRoutes(router) {
4808
5347
  const scopeErr = requireGlobalScope(auth.session, params.slug);
4809
5348
  if (scopeErr)
4810
5349
  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 });
5350
+ return cachedRead(request, {
5351
+ cache: getActuateConfig()?.cache,
5352
+ tags: [ACTUATE_TAG_ROOT, actuateTag('global', params.slug)],
5353
+ wrap: jsonWithCache,
5354
+ build: async () => {
5355
+ const ctx = buildActionContext(auth.session, db());
5356
+ const global = await getGlobal(params.slug, ctx);
5357
+ if (!global) {
5358
+ return errorResponse('Global not found', 404);
5359
+ }
5360
+ return { data: global };
5361
+ },
5362
+ });
4817
5363
  }
4818
5364
  catch (err) {
4819
5365
  return internalError(err);