@actuate-media/cms-core 0.20.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.
- package/LICENSE +21 -21
- package/dist/__tests__/actions/document-crud.test.js +74 -2
- package/dist/__tests__/actions/document-crud.test.js.map +1 -1
- package/dist/__tests__/api/cache-headers.test.d.ts +2 -0
- package/dist/__tests__/api/cache-headers.test.d.ts.map +1 -0
- package/dist/__tests__/api/cache-headers.test.js +135 -0
- package/dist/__tests__/api/cache-headers.test.js.map +1 -0
- package/dist/__tests__/api/comments-routes.test.d.ts +2 -0
- package/dist/__tests__/api/comments-routes.test.d.ts.map +1 -0
- package/dist/__tests__/api/comments-routes.test.js +266 -0
- package/dist/__tests__/api/comments-routes.test.js.map +1 -0
- package/dist/__tests__/api/notifications-routes.test.d.ts +2 -0
- package/dist/__tests__/api/notifications-routes.test.d.ts.map +1 -0
- package/dist/__tests__/api/notifications-routes.test.js +225 -0
- package/dist/__tests__/api/notifications-routes.test.js.map +1 -0
- package/dist/__tests__/api/preview-and-scheduling.test.js +53 -0
- package/dist/__tests__/api/preview-and-scheduling.test.js.map +1 -1
- package/dist/__tests__/fields/component-block.test.d.ts +2 -0
- package/dist/__tests__/fields/component-block.test.d.ts.map +1 -0
- package/dist/__tests__/fields/component-block.test.js +129 -0
- package/dist/__tests__/fields/component-block.test.js.map +1 -0
- package/dist/__tests__/fields/relations.test.js +62 -8
- package/dist/__tests__/fields/relations.test.js.map +1 -1
- package/dist/__tests__/preview-event-bus.test.d.ts +2 -0
- package/dist/__tests__/preview-event-bus.test.d.ts.map +1 -0
- package/dist/__tests__/preview-event-bus.test.js +89 -0
- package/dist/__tests__/preview-event-bus.test.js.map +1 -0
- package/dist/__tests__/preview-sse-stream.test.d.ts +2 -0
- package/dist/__tests__/preview-sse-stream.test.d.ts.map +1 -0
- package/dist/__tests__/preview-sse-stream.test.js +117 -0
- package/dist/__tests__/preview-sse-stream.test.js.map +1 -0
- package/dist/__tests__/realtime/comments.test.d.ts +2 -0
- package/dist/__tests__/realtime/comments.test.d.ts.map +1 -0
- package/dist/__tests__/realtime/comments.test.js +508 -0
- package/dist/__tests__/realtime/comments.test.js.map +1 -0
- package/dist/__tests__/realtime/gateway-ws-integration.test.d.ts +2 -0
- package/dist/__tests__/realtime/gateway-ws-integration.test.d.ts.map +1 -0
- package/dist/__tests__/realtime/gateway-ws-integration.test.js +170 -0
- package/dist/__tests__/realtime/gateway-ws-integration.test.js.map +1 -0
- package/dist/__tests__/realtime/gateway.test.d.ts +2 -0
- package/dist/__tests__/realtime/gateway.test.d.ts.map +1 -0
- package/dist/__tests__/realtime/gateway.test.js +402 -0
- package/dist/__tests__/realtime/gateway.test.js.map +1 -0
- package/dist/__tests__/realtime/notifications.test.d.ts +2 -0
- package/dist/__tests__/realtime/notifications.test.d.ts.map +1 -0
- package/dist/__tests__/realtime/notifications.test.js +317 -0
- package/dist/__tests__/realtime/notifications.test.js.map +1 -0
- package/dist/__tests__/realtime/persistence.test.d.ts +2 -0
- package/dist/__tests__/realtime/persistence.test.d.ts.map +1 -0
- package/dist/__tests__/realtime/persistence.test.js +124 -0
- package/dist/__tests__/realtime/persistence.test.js.map +1 -0
- package/dist/__tests__/schema-preview-features.test.d.ts +2 -0
- package/dist/__tests__/schema-preview-features.test.d.ts.map +1 -0
- package/dist/__tests__/schema-preview-features.test.js +42 -0
- package/dist/__tests__/schema-preview-features.test.js.map +1 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +71 -26
- package/dist/actions.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +734 -188
- package/dist/api/handlers.js.map +1 -1
- package/dist/cache/__tests__/http.test.d.ts +2 -0
- package/dist/cache/__tests__/http.test.d.ts.map +1 -0
- package/dist/cache/__tests__/http.test.js +285 -0
- package/dist/cache/__tests__/http.test.js.map +1 -0
- package/dist/cache/http.d.ts +119 -0
- package/dist/cache/http.d.ts.map +1 -0
- package/dist/cache/http.js +188 -0
- package/dist/cache/http.js.map +1 -0
- package/dist/config/types.d.ts +153 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/fields/component-block.d.ts +23 -0
- package/dist/fields/component-block.d.ts.map +1 -0
- package/dist/fields/component-block.js +137 -0
- package/dist/fields/component-block.js.map +1 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +2 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relations.d.ts.map +1 -1
- package/dist/fields/relations.js +43 -11
- package/dist/fields/relations.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/page-builder/schema.d.ts +2 -2
- package/dist/preview/event-bus.d.ts +39 -0
- package/dist/preview/event-bus.d.ts.map +1 -0
- package/dist/preview/event-bus.js +73 -0
- package/dist/preview/event-bus.js.map +1 -0
- package/dist/preview/sse-stream.d.ts +50 -0
- package/dist/preview/sse-stream.d.ts.map +1 -0
- package/dist/preview/sse-stream.js +120 -0
- package/dist/preview/sse-stream.js.map +1 -0
- package/dist/realtime/comments.d.ts +217 -0
- package/dist/realtime/comments.d.ts.map +1 -0
- package/dist/realtime/comments.js +282 -0
- package/dist/realtime/comments.js.map +1 -0
- package/dist/realtime/gateway.d.ts +107 -0
- package/dist/realtime/gateway.d.ts.map +1 -0
- package/dist/realtime/gateway.js +215 -0
- package/dist/realtime/gateway.js.map +1 -0
- package/dist/realtime/index.d.ts +18 -0
- package/dist/realtime/index.d.ts.map +1 -0
- package/dist/realtime/index.js +18 -0
- package/dist/realtime/index.js.map +1 -0
- package/dist/realtime/notifications.d.ts +209 -0
- package/dist/realtime/notifications.d.ts.map +1 -0
- package/dist/realtime/notifications.js +209 -0
- package/dist/realtime/notifications.js.map +1 -0
- package/dist/realtime/persistence.d.ts +72 -0
- package/dist/realtime/persistence.d.ts.map +1 -0
- package/dist/realtime/persistence.js +53 -0
- package/dist/realtime/persistence.js.map +1 -0
- package/package.json +13 -4
- package/prisma/cms-schema.prisma +437 -306
- package/prisma/migrations/0001_init/migration.sql +384 -384
- package/prisma/migrations/0002_folders/migration.sql +39 -39
- package/prisma/migrations/0003_search_and_webhooks/migration.sql +50 -50
- package/prisma/migrations/0004_script_tags/migration.sql +21 -21
- package/prisma/migrations/0005_password_reset_tokens/migration.sql +20 -20
- package/prisma/migrations/0006_page_builder/migration.sql +38 -38
- package/prisma/migrations/migration_lock.toml +3 -3
- package/prisma/schema.prisma +563 -549
- package/generated/browser.ts +0 -109
- package/generated/client.ts +0 -133
- package/generated/commonInputTypes.ts +0 -709
- package/generated/enums.ts +0 -125
- package/generated/internal/class.ts +0 -376
- package/generated/internal/prismaNamespace.ts +0 -2617
- package/generated/internal/prismaNamespaceBrowser.ts +0 -611
- package/generated/models/ApiKey.ts +0 -1550
- package/generated/models/AuditLog.ts +0 -1206
- package/generated/models/BackupRecord.ts +0 -1250
- package/generated/models/ContentLock.ts +0 -1472
- package/generated/models/ContentTemplate.ts +0 -1416
- package/generated/models/Document.ts +0 -3005
- package/generated/models/Folder.ts +0 -1904
- package/generated/models/FormSubmission.ts +0 -1200
- package/generated/models/InAppNotification.ts +0 -1457
- package/generated/models/Media.ts +0 -2340
- package/generated/models/MediaUsage.ts +0 -1472
- package/generated/models/OAuthAccount.ts +0 -1463
- package/generated/models/Redirect.ts +0 -1284
- package/generated/models/Session.ts +0 -1492
- package/generated/models/Site.ts +0 -1206
- package/generated/models/User.ts +0 -3513
- package/generated/models/Version.ts +0 -1511
- package/generated/models/WorkflowState.ts +0 -1514
- package/generated/models.ts +0 -29
package/dist/api/handlers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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
|
|
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
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
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
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
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);
|