@actuate-media/cms-core 0.26.0 → 0.27.1

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 (45) hide show
  1. package/dist/__tests__/api/collections-discovery.test.js +47 -0
  2. package/dist/__tests__/api/collections-discovery.test.js.map +1 -1
  3. package/dist/__tests__/api/page-sections-routes.test.d.ts +2 -0
  4. package/dist/__tests__/api/page-sections-routes.test.d.ts.map +1 -0
  5. package/dist/__tests__/api/page-sections-routes.test.js +271 -0
  6. package/dist/__tests__/api/page-sections-routes.test.js.map +1 -0
  7. package/dist/__tests__/api/stats-collection-filter.test.d.ts +2 -0
  8. package/dist/__tests__/api/stats-collection-filter.test.d.ts.map +1 -0
  9. package/dist/__tests__/api/stats-collection-filter.test.js +190 -0
  10. package/dist/__tests__/api/stats-collection-filter.test.js.map +1 -0
  11. package/dist/__tests__/api/stats-seo-cache.test.d.ts +2 -0
  12. package/dist/__tests__/api/stats-seo-cache.test.d.ts.map +1 -0
  13. package/dist/__tests__/api/stats-seo-cache.test.js +96 -0
  14. package/dist/__tests__/api/stats-seo-cache.test.js.map +1 -0
  15. package/dist/api/handlers.d.ts +2 -0
  16. package/dist/api/handlers.d.ts.map +1 -1
  17. package/dist/api/handlers.js +465 -16
  18. package/dist/api/handlers.js.map +1 -1
  19. package/dist/config/types.d.ts +8 -0
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/sections/__tests__/sections.test.d.ts +2 -0
  22. package/dist/sections/__tests__/sections.test.d.ts.map +1 -0
  23. package/dist/sections/__tests__/sections.test.js +215 -0
  24. package/dist/sections/__tests__/sections.test.js.map +1 -0
  25. package/dist/sections/helpers.d.ts +62 -0
  26. package/dist/sections/helpers.d.ts.map +1 -0
  27. package/dist/sections/helpers.js +202 -0
  28. package/dist/sections/helpers.js.map +1 -0
  29. package/dist/sections/index.d.ts +13 -0
  30. package/dist/sections/index.d.ts.map +1 -0
  31. package/dist/sections/index.js +12 -0
  32. package/dist/sections/index.js.map +1 -0
  33. package/dist/sections/registry.d.ts +27 -0
  34. package/dist/sections/registry.d.ts.map +1 -0
  35. package/dist/sections/registry.js +349 -0
  36. package/dist/sections/registry.js.map +1 -0
  37. package/dist/sections/types.d.ts +127 -0
  38. package/dist/sections/types.d.ts.map +1 -0
  39. package/dist/sections/types.js +21 -0
  40. package/dist/sections/types.js.map +1 -0
  41. package/dist/sections/validate.d.ts +10 -0
  42. package/dist/sections/validate.d.ts.map +1 -0
  43. package/dist/sections/validate.js +92 -0
  44. package/dist/sections/validate.js.map +1 -0
  45. package/package.json +6 -1
@@ -0,0 +1,96 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { handleActuateAPI } from '../../api/index.js';
3
+ import { __resetDashboardSeoCache } from '../../api/handlers.js';
4
+ import { createSession } from '../../auth/session.js';
5
+ import { initDB } from '../../db.js';
6
+ import { generateToken as generateCsrfToken } from '../../security/csrf.js';
7
+ /**
8
+ * `/stats` caches its average-SEO-score computation (a read + scan over
9
+ * up to 200 documents) per content-scope for a short TTL. These tests
10
+ * assert the cache is actually consulted: a second request within the
11
+ * window must NOT re-issue the 200-document SEO read, and clearing the
12
+ * cache forces a recompute.
13
+ */
14
+ const SECRET = 'test-secret-for-stats-seo-cache-aaaaaaaaaaaaaaaa';
15
+ function createMockDB(rows) {
16
+ let seoReadCount = 0;
17
+ const allRows = rows.map((r, i) => ({
18
+ id: `d${i}`,
19
+ title: `Doc ${i}`,
20
+ status: 'PUBLISHED',
21
+ collection: 'posts',
22
+ updatedAt: new Date(),
23
+ createdById: 'u1',
24
+ createdBy: { name: 'Author', email: 'a@example.com' },
25
+ data: r.data,
26
+ }));
27
+ const db = {
28
+ session: { findUnique: async () => ({ revokedAt: null }) },
29
+ user: { count: async () => 0 },
30
+ media: { count: async () => 0 },
31
+ formSubmission: { count: async () => 0 },
32
+ webhookEndpoint: { count: async () => 0 },
33
+ document: {
34
+ count: async () => allRows.length,
35
+ // The SEO read is the only `findMany` with `take: 200` + `select.data`.
36
+ findMany: async ({ take, select }) => {
37
+ if (take === 200 && select?.data)
38
+ seoReadCount++;
39
+ return allRows;
40
+ },
41
+ groupBy: async () => [],
42
+ },
43
+ };
44
+ return { db, getSeoReadCount: () => seoReadCount };
45
+ }
46
+ async function sessionCookie() {
47
+ const token = await createSession({ userId: 'user-1', role: 'ADMIN', sessionId: 'session-1' }, { secret: SECRET });
48
+ const csrf = await generateCsrfToken();
49
+ return `actuate_session=${token}; actuate_csrf=${csrf}`;
50
+ }
51
+ const POSTS_CONFIG = { collections: { posts: { slug: 'posts', fields: {} } } };
52
+ describe('GET /api/cms/stats — SEO score cache', () => {
53
+ const originalSecret = process.env.CMS_SECRET;
54
+ beforeEach(() => {
55
+ process.env.CMS_SECRET = SECRET;
56
+ __resetDashboardSeoCache();
57
+ });
58
+ afterEach(() => {
59
+ if (originalSecret === undefined)
60
+ delete process.env.CMS_SECRET;
61
+ else
62
+ process.env.CMS_SECRET = originalSecret;
63
+ delete globalThis.__actuateConfig;
64
+ __resetDashboardSeoCache();
65
+ });
66
+ it('computes the score once and serves repeat requests from cache', async () => {
67
+ const { db, getSeoReadCount } = createMockDB([
68
+ { data: { seoTitle: 'a', metaDescription: 'b' } }, // 50
69
+ { data: { seoTitle: 'a', metaDescription: 'b', canonical: 'c', schemaType: 'Article' } }, // 100
70
+ ]);
71
+ initDB(db);
72
+ const handler = handleActuateAPI({ prismaClient: db, config: POSTS_CONFIG });
73
+ const cookie = await sessionCookie();
74
+ const call = () => handler(new Request('https://example.com/api/cms/stats', { headers: { cookie } }));
75
+ const first = (await (await call()).json());
76
+ expect(first.data.avgSeoScore).toBe(75);
77
+ expect(getSeoReadCount()).toBe(1);
78
+ const second = (await (await call()).json());
79
+ expect(second.data.avgSeoScore).toBe(75);
80
+ // Served from cache — no second 200-document read.
81
+ expect(getSeoReadCount()).toBe(1);
82
+ });
83
+ it('recomputes after the cache is cleared', async () => {
84
+ const { db, getSeoReadCount } = createMockDB([{ data: { seoTitle: 'a' } }]); // 25
85
+ initDB(db);
86
+ const handler = handleActuateAPI({ prismaClient: db, config: POSTS_CONFIG });
87
+ const cookie = await sessionCookie();
88
+ const call = () => handler(new Request('https://example.com/api/cms/stats', { headers: { cookie } }));
89
+ await call();
90
+ expect(getSeoReadCount()).toBe(1);
91
+ __resetDashboardSeoCache();
92
+ await call();
93
+ expect(getSeoReadCount()).toBe(2);
94
+ });
95
+ });
96
+ //# sourceMappingURL=stats-seo-cache.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stats-seo-cache.test.js","sourceRoot":"","sources":["../../../src/__tests__/api/stats-seo-cache.test.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,6 @@
1
1
  import type { ApiRouter } from './router.js';
2
+ /** Test-only: reset the SEO score cache so cases don't bleed into each other. */
3
+ export declare function __resetDashboardSeoCache(): void;
2
4
  export declare function parseCookieHeader(cookieHeader: string): Record<string, string>;
3
5
  export declare function registerCMSRoutes(router: ApiRouter): void;
4
6
  //# sourceMappingURL=handlers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAwnB5C,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;AA2ND,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA+lOzD"}
1
+ {"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../../src/api/handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAogB5C,iFAAiF;AACjF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AA8JD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAS9E;AAiQD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA8kPzD"}
@@ -33,6 +33,7 @@ import { createSSEPresenceAdapter } from '../presence/index.js';
33
33
  import { BUILT_IN_TEMPLATES } from '../page-builder/templates.js';
34
34
  import { validateTree } from '../page-builder/validate.js';
35
35
  import { auditAccessibility, fixAccessibility } from '../page-builder/a11y-fix.js';
36
+ import { SECTION_TYPE_LIST, getSectionType, getSectionTypesForScope, buildSection, coerceSections, coercePostHeader, defaultPostHeader, addSection as addSectionToList, removeSection as removeSectionFromList, reorderSections as reorderSectionList, setSectionVisibility, updateSectionContent as updateSectionContentInList, updateSectionSettings as updateSectionSettingsInList, updateSectionMeta as updateSectionMetaInList, validateSectionContent, } from '../sections/index.js';
36
37
  import { getClientIp, isResolvedIp } from '../security/client-ip.js';
37
38
  import { safeFetch, SsrfBlockedError } from '../security/safe-fetch.js';
38
39
  import { redactSecrets } from '../security/redact.js';
@@ -385,6 +386,25 @@ async function safeFindMany(model, args) {
385
386
  return [];
386
387
  }
387
388
  }
389
+ /**
390
+ * Short-TTL cache for the dashboard's average SEO score.
391
+ *
392
+ * `/stats` otherwise reads the `data` JSON of up to 200 published
393
+ * documents and scores each one on every load — the single heaviest
394
+ * part of the endpoint. The score barely moves minute-to-minute, so we
395
+ * cache it per content-scope (the JSON-stringified Prisma `where`, which
396
+ * encodes the registered collection allowlist) for a short window.
397
+ *
398
+ * Module-level so it survives across requests within a warm isolate;
399
+ * a cold start simply recomputes once. Keyed by scope so multi-config
400
+ * isolates never read each other's score.
401
+ */
402
+ let dashboardSeoCache = null;
403
+ const DASHBOARD_SEO_CACHE_TTL_MS = 60_000;
404
+ /** Test-only: reset the SEO score cache so cases don't bleed into each other. */
405
+ export function __resetDashboardSeoCache() {
406
+ dashboardSeoCache = null;
407
+ }
388
408
  function isAllowedStorageUrl(url) {
389
409
  try {
390
410
  const parsed = new URL(url);
@@ -708,6 +728,41 @@ class ModelNotAvailableError extends Error {
708
728
  this.model = model;
709
729
  }
710
730
  }
731
+ /**
732
+ * Resolve a collection definition from the runtime config, tolerating both
733
+ * the array and the record (keyed-by-slug) shapes consumers use.
734
+ */
735
+ function findCollectionDef(slug) {
736
+ const collections = getActuateConfig()?.collections;
737
+ if (Array.isArray(collections)) {
738
+ return collections.find((c) => c?.slug === slug);
739
+ }
740
+ if (collections && typeof collections === 'object') {
741
+ const rec = collections;
742
+ return rec[slug] ?? Object.values(rec).find((c) => c?.slug === slug);
743
+ }
744
+ return undefined;
745
+ }
746
+ /**
747
+ * True when `collection` declares a `sections` JSON field — the storage
748
+ * contract the flat Page Editor + section endpoints rely on. Guards against
749
+ * silently dropping section writes on a collection that never declared the
750
+ * field (the field-access layer strips undeclared keys).
751
+ */
752
+ function collectionSupportsSections(slug) {
753
+ const def = findCollectionDef(slug);
754
+ if (!def)
755
+ return false;
756
+ const fields = def.fields;
757
+ if (Array.isArray(fields)) {
758
+ return fields.some((f) => f?.name === 'sections' && f?.type === 'json');
759
+ }
760
+ if (fields && typeof fields === 'object') {
761
+ const f = fields.sections;
762
+ return f?.type === 'json';
763
+ }
764
+ return false;
765
+ }
711
766
  export function registerCMSRoutes(router) {
712
767
  const rawDb = () => getDB();
713
768
  const db = () => {
@@ -811,6 +866,14 @@ export function registerCMSRoutes(router) {
811
866
  type: def?.type ?? 'page',
812
867
  urlPrefix: def?.urlPrefix ?? slug,
813
868
  hidden: def?.admin?.hidden === true,
869
+ // Surface the admin metadata the Posts area needs to render
870
+ // type tabs / cards (description, icon, accent color, group).
871
+ // None are sensitive — they all already live on the public
872
+ // collection definition the admin client receives via props.
873
+ description: def?.admin?.description ?? null,
874
+ icon: def?.admin?.icon ?? null,
875
+ color: def?.admin?.color ?? null,
876
+ group: def?.admin?.group ?? null,
814
877
  fieldCount: fieldSummary.length,
815
878
  fields: fieldSummary,
816
879
  };
@@ -1705,6 +1768,337 @@ export function registerCMSRoutes(router) {
1705
1768
  }
1706
1769
  });
1707
1770
  // ---------------------------------------------------------------------------
1771
+ // Page section routes (flat Page Editor model)
1772
+ //
1773
+ // These operate on a page document's `data.sections` JSON array — the same
1774
+ // model the visual Page Editor reads/writes — so an AI agent driving the
1775
+ // CMS from Cursor/VS Code via MCP produces real, editable, publishable
1776
+ // sections rather than disposable markup. All mutations go through the
1777
+ // audited `updateDocument` path (versions, hooks, cache invalidation,
1778
+ // live-preview push) and reuse collection scope checks.
1779
+ //
1780
+ // Discovery + validation are deterministic (no LLM) and only need auth.
1781
+ // ---------------------------------------------------------------------------
1782
+ // GET /page-sections/types — discover registered section types + schemas.
1783
+ // Optional `?scope=page|post` filters to types usable on that surface so an
1784
+ // agent only builds sections that have a working renderer in that context.
1785
+ router.get('/page-sections/types', async (request) => {
1786
+ try {
1787
+ const auth = await requireAuth(request);
1788
+ if (auth.error)
1789
+ return auth.error;
1790
+ const scope = new URL(request.url).searchParams.get('scope');
1791
+ const data = scope === 'page' || scope === 'post'
1792
+ ? getSectionTypesForScope(scope)
1793
+ : SECTION_TYPE_LIST;
1794
+ return json({ data });
1795
+ }
1796
+ catch (err) {
1797
+ return internalError(err, 'list section types');
1798
+ }
1799
+ });
1800
+ // POST /page-sections/validate — validate section content against its
1801
+ // type schema. Body: { sectionType, content, settings? }.
1802
+ router.post('/page-sections/validate', async (request) => {
1803
+ try {
1804
+ const auth = await requireAuth(request);
1805
+ if (auth.error)
1806
+ return auth.error;
1807
+ const body = (await request.json().catch(() => null));
1808
+ const sectionType = typeof body?.sectionType === 'string' ? body.sectionType : '';
1809
+ if (!sectionType)
1810
+ return errorResponse('sectionType is required', 400);
1811
+ const content = body?.content && typeof body.content === 'object'
1812
+ ? body.content
1813
+ : {};
1814
+ const settings = body?.settings && typeof body.settings === 'object'
1815
+ ? body.settings
1816
+ : undefined;
1817
+ const result = validateSectionContent(sectionType, content, settings);
1818
+ return json({ data: result });
1819
+ }
1820
+ catch (err) {
1821
+ return internalError(err, 'validate section');
1822
+ }
1823
+ });
1824
+ // Shared: load a page document's sections, or a Response on error.
1825
+ const loadPageSections = async (collection, id, ctx) => {
1826
+ if (!collectionSupportsSections(collection)) {
1827
+ return errorResponse(`Collection "${collection}" does not declare a "sections" JSON field, so it cannot store page sections.`, 400);
1828
+ }
1829
+ const doc = await getDocument(collection, id, ctx);
1830
+ if (!doc)
1831
+ return errorResponse('Page not found', 404);
1832
+ const data = doc.data ?? {};
1833
+ return { sections: coerceSections(data.sections) };
1834
+ };
1835
+ // Shared: persist a mutated section array via the audited update path.
1836
+ const persistPageSections = async (collection, id, sections, ctx, userId) => {
1837
+ const doc = await updateDocument(collection, id, { sections }, ctx);
1838
+ await logEvent({
1839
+ event: 'document_updated',
1840
+ userId,
1841
+ details: { collection, documentId: id, action: 'page_sections_updated' },
1842
+ });
1843
+ notifyPreviewUpdate(collection, id, {
1844
+ values: doc.data,
1845
+ status: doc.status,
1846
+ updatedAt: doc.updatedAt?.toISOString?.(),
1847
+ });
1848
+ const data = doc.data ?? {};
1849
+ return coerceSections(data.sections);
1850
+ };
1851
+ // GET /page-sections/:collection/:id — list a page's sections.
1852
+ router.get('/page-sections/:collection/:id', async (request, params) => {
1853
+ try {
1854
+ const auth = await requireAuth(request);
1855
+ if (auth.error)
1856
+ return auth.error;
1857
+ const scopeErr = requireCollectionScope(auth.session, params.collection, 'read');
1858
+ if (scopeErr)
1859
+ return scopeErr;
1860
+ const ctx = buildActionContext(auth.session, db());
1861
+ const loaded = await loadPageSections(params.collection, params.id, ctx);
1862
+ if (loaded instanceof Response)
1863
+ return loaded;
1864
+ return json({ data: loaded.sections });
1865
+ }
1866
+ catch (err) {
1867
+ return internalError(err, 'list page sections');
1868
+ }
1869
+ });
1870
+ // POST /page-sections/:collection/:id — add a section.
1871
+ // Body: { sectionType, name?, internalLabel?, visible?, content?, settings?, position? }.
1872
+ router.post('/page-sections/:collection/:id', async (request, params) => {
1873
+ try {
1874
+ const auth = await requireAuth(request);
1875
+ if (auth.error)
1876
+ return auth.error;
1877
+ const scopeErr = requireCollectionScope(auth.session, params.collection, 'update');
1878
+ if (scopeErr)
1879
+ return scopeErr;
1880
+ const body = (await request.json().catch(() => null));
1881
+ const sectionType = typeof body?.sectionType === 'string' ? body.sectionType : '';
1882
+ if (!getSectionType(sectionType)) {
1883
+ return errorResponse(`Unknown section type "${sectionType}". Call GET /page-sections/types for valid ids.`, 400);
1884
+ }
1885
+ const ctx = buildActionContext(auth.session, db());
1886
+ const loaded = await loadPageSections(params.collection, params.id, ctx);
1887
+ if (loaded instanceof Response)
1888
+ return loaded;
1889
+ const section = buildSection(sectionType, {
1890
+ name: typeof body?.name === 'string' ? body.name : undefined,
1891
+ internalLabel: typeof body?.internalLabel === 'string' ? body.internalLabel : undefined,
1892
+ visible: body?.visible !== false,
1893
+ content: body?.content && typeof body.content === 'object'
1894
+ ? body.content
1895
+ : undefined,
1896
+ settings: body?.settings && typeof body.settings === 'object'
1897
+ ? body.settings
1898
+ : undefined,
1899
+ });
1900
+ const position = typeof body?.position === 'number' ? body.position : undefined;
1901
+ const next = addSectionToList(loaded.sections, section, position);
1902
+ const saved = await persistPageSections(params.collection, params.id, next, ctx, auth.session.userId);
1903
+ const validation = validateSectionContent(section.sectionType, section.content, section.settings);
1904
+ return json({ data: { section, sections: saved, validation } }, 201);
1905
+ }
1906
+ catch (err) {
1907
+ return internalError(err, 'add page section');
1908
+ }
1909
+ });
1910
+ // POST /page-sections/:collection/:id/reorder — reorder by id list.
1911
+ // Registered BEFORE the :sectionId routes so "reorder" isn't captured as a
1912
+ // section id (the router matches in registration order).
1913
+ router.post('/page-sections/:collection/:id/reorder', async (request, params) => {
1914
+ try {
1915
+ const auth = await requireAuth(request);
1916
+ if (auth.error)
1917
+ return auth.error;
1918
+ const scopeErr = requireCollectionScope(auth.session, params.collection, 'update');
1919
+ if (scopeErr)
1920
+ return scopeErr;
1921
+ const body = (await request.json().catch(() => null));
1922
+ const orderedIds = Array.isArray(body?.orderedIds)
1923
+ ? body.orderedIds.filter((x) => typeof x === 'string')
1924
+ : null;
1925
+ if (!orderedIds)
1926
+ return errorResponse('orderedIds (string[]) is required', 400);
1927
+ const ctx = buildActionContext(auth.session, db());
1928
+ const loaded = await loadPageSections(params.collection, params.id, ctx);
1929
+ if (loaded instanceof Response)
1930
+ return loaded;
1931
+ const next = reorderSectionList(loaded.sections, orderedIds);
1932
+ const saved = await persistPageSections(params.collection, params.id, next, ctx, auth.session.userId);
1933
+ return json({ data: saved });
1934
+ }
1935
+ catch (err) {
1936
+ return internalError(err, 'reorder page sections');
1937
+ }
1938
+ });
1939
+ // PATCH /page-sections/:collection/:id/:sectionId — update a section.
1940
+ // Body: { content?, settings?, name?, internalLabel?, visible? }.
1941
+ router.patch('/page-sections/:collection/:id/:sectionId', async (request, params) => {
1942
+ try {
1943
+ const auth = await requireAuth(request);
1944
+ if (auth.error)
1945
+ return auth.error;
1946
+ const scopeErr = requireCollectionScope(auth.session, params.collection, 'update');
1947
+ if (scopeErr)
1948
+ return scopeErr;
1949
+ const body = (await request.json().catch(() => null));
1950
+ const ctx = buildActionContext(auth.session, db());
1951
+ const loaded = await loadPageSections(params.collection, params.id, ctx);
1952
+ if (loaded instanceof Response)
1953
+ return loaded;
1954
+ const sectionId = params.sectionId;
1955
+ if (!loaded.sections.some((s) => s.id === sectionId)) {
1956
+ return errorResponse(`Section "${sectionId}" not found on this page.`, 404);
1957
+ }
1958
+ let next = loaded.sections;
1959
+ if (body?.content && typeof body.content === 'object') {
1960
+ next = updateSectionContentInList(next, sectionId, body.content);
1961
+ }
1962
+ if (body?.settings && typeof body.settings === 'object') {
1963
+ next = updateSectionSettingsInList(next, sectionId, body.settings);
1964
+ }
1965
+ const meta = {};
1966
+ if (typeof body?.name === 'string')
1967
+ meta.name = body.name;
1968
+ if (typeof body?.internalLabel === 'string')
1969
+ meta.internalLabel = body.internalLabel;
1970
+ if (Object.keys(meta).length > 0)
1971
+ next = updateSectionMetaInList(next, sectionId, meta);
1972
+ if (typeof body?.visible === 'boolean') {
1973
+ next = setSectionVisibility(next, sectionId, body.visible);
1974
+ }
1975
+ const saved = await persistPageSections(params.collection, params.id, next, ctx, auth.session.userId);
1976
+ const updated = saved.find((s) => s.id === sectionId);
1977
+ const validation = updated
1978
+ ? validateSectionContent(updated.sectionType, updated.content, updated.settings)
1979
+ : undefined;
1980
+ return json({ data: { section: updated, sections: saved, validation } });
1981
+ }
1982
+ catch (err) {
1983
+ return internalError(err, 'update page section');
1984
+ }
1985
+ });
1986
+ // DELETE /page-sections/:collection/:id/:sectionId — remove a section.
1987
+ router.delete('/page-sections/:collection/:id/:sectionId', async (request, params) => {
1988
+ try {
1989
+ const auth = await requireAuth(request);
1990
+ if (auth.error)
1991
+ return auth.error;
1992
+ const scopeErr = requireCollectionScope(auth.session, params.collection, 'update');
1993
+ if (scopeErr)
1994
+ return scopeErr;
1995
+ const ctx = buildActionContext(auth.session, db());
1996
+ const loaded = await loadPageSections(params.collection, params.id, ctx);
1997
+ if (loaded instanceof Response)
1998
+ return loaded;
1999
+ const sectionId = params.sectionId;
2000
+ if (!loaded.sections.some((s) => s.id === sectionId)) {
2001
+ return errorResponse(`Section "${sectionId}" not found on this page.`, 404);
2002
+ }
2003
+ const next = removeSectionFromList(loaded.sections, sectionId);
2004
+ const saved = await persistPageSections(params.collection, params.id, next, ctx, auth.session.userId);
2005
+ return json({ data: { sections: saved } });
2006
+ }
2007
+ catch (err) {
2008
+ return internalError(err, 'remove page section');
2009
+ }
2010
+ });
2011
+ // ---------------------------------------------------------------------------
2012
+ // Post template routes
2013
+ //
2014
+ // A post type's template is a single document in the hidden
2015
+ // `post-templates` collection, matched by `slug` == the post-type slug. It
2016
+ // stores the design-driven header layout (`data.header`) and the default
2017
+ // section list (`data.sections`) new posts inherit. Lets an external AI
2018
+ // agent (Cursor/VS Code) read a type's template and build it from
2019
+ // scope=post section types discovered via `/page-sections/types?scope=post`.
2020
+ // ---------------------------------------------------------------------------
2021
+ const POST_TEMPLATES_COLLECTION = 'post-templates';
2022
+ const findPostTemplateDoc = async (postType, ctx) => {
2023
+ const res = await listDocuments({ collection: POST_TEMPLATES_COLLECTION, pageSize: 200 }, ctx);
2024
+ const docs = res.docs ?? [];
2025
+ return (docs.find((d) => (d.slug ?? d.data?.slug) === postType) ?? null);
2026
+ };
2027
+ const templateResponse = (postType, doc) => {
2028
+ if (!doc) {
2029
+ return { postType, docId: null, header: defaultPostHeader(), sections: [] };
2030
+ }
2031
+ const data = doc.data ?? {};
2032
+ return {
2033
+ postType,
2034
+ docId: doc.id,
2035
+ header: coercePostHeader(data.header),
2036
+ sections: coerceSections(data.sections),
2037
+ };
2038
+ };
2039
+ // GET /post-templates/:type — read a post type's template (header +
2040
+ // default sections). Returns an unsaved default when none exists yet.
2041
+ router.get('/post-templates/:type', async (request, params) => {
2042
+ try {
2043
+ const auth = await requireAuth(request);
2044
+ if (auth.error)
2045
+ return auth.error;
2046
+ const scopeErr = requireCollectionScope(auth.session, POST_TEMPLATES_COLLECTION, 'read');
2047
+ if (scopeErr)
2048
+ return scopeErr;
2049
+ const ctx = buildActionContext(auth.session, db());
2050
+ const doc = await findPostTemplateDoc(params.type, ctx);
2051
+ return json({ data: templateResponse(params.type, doc) });
2052
+ }
2053
+ catch (err) {
2054
+ return internalError(err, 'get post template');
2055
+ }
2056
+ });
2057
+ // PUT /post-templates/:type — create or replace a post type's template.
2058
+ // Body: { header?, sections? }. Header is coerced to a full PostHeaderConfig
2059
+ // and sections are coerced (unknown section types dropped) so the stored
2060
+ // template always renders.
2061
+ router.put('/post-templates/:type', async (request, params) => {
2062
+ try {
2063
+ const auth = await requireAuth(request);
2064
+ if (auth.error)
2065
+ return auth.error;
2066
+ const scopeErr = requireCollectionScope(auth.session, POST_TEMPLATES_COLLECTION, 'update');
2067
+ if (scopeErr)
2068
+ return scopeErr;
2069
+ const body = (await request.json().catch(() => null));
2070
+ const header = coercePostHeader(body?.header);
2071
+ const sections = coerceSections(body?.sections);
2072
+ const ctx = buildActionContext(auth.session, db());
2073
+ const existing = await findPostTemplateDoc(params.type, ctx);
2074
+ const data = {
2075
+ title: `${params.type} template`,
2076
+ slug: params.type,
2077
+ postType: params.type,
2078
+ header,
2079
+ sections,
2080
+ status: 'PUBLISHED',
2081
+ };
2082
+ const doc = existing
2083
+ ? await updateDocument(POST_TEMPLATES_COLLECTION, existing.id, data, ctx)
2084
+ : await createDocument(POST_TEMPLATES_COLLECTION, data, ctx);
2085
+ await logEvent({
2086
+ event: existing ? 'document_updated' : 'document_created',
2087
+ userId: auth.session.userId,
2088
+ details: {
2089
+ collection: POST_TEMPLATES_COLLECTION,
2090
+ documentId: doc.id,
2091
+ action: 'post_template_saved',
2092
+ postType: params.type,
2093
+ },
2094
+ });
2095
+ return json({ data: templateResponse(params.type, doc) });
2096
+ }
2097
+ catch (err) {
2098
+ return internalError(err, 'save post template');
2099
+ }
2100
+ });
2101
+ // ---------------------------------------------------------------------------
1708
2102
  // Media routes
1709
2103
  // ---------------------------------------------------------------------------
1710
2104
  router.get('/media', async (request) => {
@@ -2578,6 +2972,63 @@ export function registerCMSRoutes(router) {
2578
2972
  score += 25;
2579
2973
  return score;
2580
2974
  }
2975
+ /**
2976
+ * Average SEO score across published content, cached per content-scope
2977
+ * for `DASHBOARD_SEO_CACHE_TTL_MS`. On a cache hit this returns without
2978
+ * touching the database — avoiding the 200-document `data` read + scan
2979
+ * that dominates `/stats`. Failures from `safeFindMany` surface as an
2980
+ * empty list (score 0) and are not cached, so a transient DB blip
2981
+ * doesn't pin a stale 0.
2982
+ */
2983
+ async function getCachedAvgSeoScore(model, where) {
2984
+ const key = JSON.stringify(where);
2985
+ const now = Date.now();
2986
+ if (dashboardSeoCache && dashboardSeoCache.key === key && dashboardSeoCache.expires > now) {
2987
+ return dashboardSeoCache.value;
2988
+ }
2989
+ const docs = await safeFindMany(model, {
2990
+ where,
2991
+ select: { data: true },
2992
+ take: 200,
2993
+ });
2994
+ let value = 0;
2995
+ if (docs.length > 0) {
2996
+ const total = docs.reduce((sum, doc) => sum + computeLightSeoScore(doc.data ?? {}), 0);
2997
+ value = Math.round(total / docs.length);
2998
+ }
2999
+ dashboardSeoCache = { key, value, expires: now + DASHBOARD_SEO_CACHE_TTL_MS };
3000
+ return value;
3001
+ }
3002
+ /**
3003
+ * Collection slugs that are written by the benchmark suite and a few
3004
+ * other internal/test fixtures. Documents in these collections are
3005
+ * never editor-facing content and should never appear in the
3006
+ * dashboard's stats, counts, or recent-activity feed.
3007
+ *
3008
+ * This list is the fallback when the consumer ships collection
3009
+ * definitions only to the admin client (e.g. `apps/dev`) and
3010
+ * `getActuateConfig()?.collections` is therefore empty on the
3011
+ * server. Production consumers normally pass `collections` to
3012
+ * `handleActuateAPI({ config })`, in which case the registered
3013
+ * slugs become the allowlist and this denylist is unused.
3014
+ */
3015
+ const INTERNAL_COLLECTION_SLUGS = ['benchmarks', 'bench'];
3016
+ /**
3017
+ * Build the Prisma `where` clause for "user-facing content".
3018
+ *
3019
+ * - If the CMS config registered collections, restrict to those
3020
+ * slugs — anything else is orphan/test data.
3021
+ * - Otherwise fall back to excluding the internal denylist.
3022
+ *
3023
+ * Always excludes soft-deleted rows. Callers can spread additional
3024
+ * filters on top (`status: 'PUBLISHED'`, etc.).
3025
+ */
3026
+ function userContentWhere(extra = {}) {
3027
+ const cfg = getActuateConfig();
3028
+ const registered = Object.keys(cfg?.collections ?? {});
3029
+ const collectionFilter = registered.length > 0 ? { in: registered } : { notIn: [...INTERNAL_COLLECTION_SLUGS] };
3030
+ return { deletedAt: null, collection: collectionFilter, ...extra };
3031
+ }
2581
3032
  router.get('/stats', async (request) => {
2582
3033
  try {
2583
3034
  if (!isSecretMissing()) {
@@ -2592,8 +3043,13 @@ export function registerCMSRoutes(router) {
2592
3043
  catch {
2593
3044
  return json({ data: EMPTY_STATS });
2594
3045
  }
2595
- const [docResult, mediaResult, userResult, recentResult, collGroupResult, statusGroupResult, formResult, seoDocsResult, webhookTotalResult, webhookActiveResult,] = await Promise.allSettled([
2596
- safeCount(d.document, { deletedAt: null }),
3046
+ // `userContentWhere()` filters out benchmark / orphan / internal
3047
+ // collections so the dashboard surfaces editor content only. See
3048
+ // the helper above for the allowlist-vs-denylist behaviour.
3049
+ const contentWhere = userContentWhere();
3050
+ const publishedContentWhere = userContentWhere({ status: 'PUBLISHED' });
3051
+ const [docResult, mediaResult, userResult, recentResult, collGroupResult, statusGroupResult, formResult, seoScoreResult, webhookTotalResult, webhookActiveResult,] = await Promise.allSettled([
3052
+ safeCount(d.document, contentWhere),
2597
3053
  safeCount(d.media),
2598
3054
  safeCount(d.user),
2599
3055
  safeFindMany(d.document, {
@@ -2603,7 +3059,7 @@ export function registerCMSRoutes(router) {
2603
3059
  // -> Prisma Postgres path that saves ~80 ms (one full network
2604
3060
  // RTT) on every dashboard load.
2605
3061
  relationLoadStrategy: 'join',
2606
- where: { deletedAt: null },
3062
+ where: contentWhere,
2607
3063
  orderBy: { updatedAt: 'desc' },
2608
3064
  take: 20,
2609
3065
  select: {
@@ -2616,14 +3072,12 @@ export function registerCMSRoutes(router) {
2616
3072
  createdBy: { select: { name: true, email: true } },
2617
3073
  },
2618
3074
  }),
2619
- safeGroupBy(d.document, ['collection'], { deletedAt: null }),
2620
- safeGroupBy(d.document, ['status'], { deletedAt: null }),
3075
+ safeGroupBy(d.document, ['collection'], contentWhere),
3076
+ safeGroupBy(d.document, ['status'], contentWhere),
2621
3077
  safeCount(d.formSubmission),
2622
- safeFindMany(d.document, {
2623
- where: { deletedAt: null, status: 'PUBLISHED' },
2624
- select: { data: true },
2625
- take: 200,
2626
- }),
3078
+ // Cached per content-scope so the 200-doc SEO read+scan runs at
3079
+ // most once per TTL window across warm requests.
3080
+ getCachedAvgSeoScore(d.document, publishedContentWhere),
2627
3081
  // Cheap counts so the dashboard's "Content Delivery" tile doesn't need
2628
3082
  // a second round-trip. `webhookEndpoint` may not exist on every host,
2629
3083
  // so `safeCount` swallows missing-model errors and returns 0.
@@ -2640,12 +3094,7 @@ export function registerCMSRoutes(router) {
2640
3094
  for (const g of statusGroupResult.value)
2641
3095
  statusCounts[g.status] = g._count._all;
2642
3096
  }
2643
- let avgSeoScore = 0;
2644
- if (seoDocsResult.status === 'fulfilled' && seoDocsResult.value.length > 0) {
2645
- const docs = seoDocsResult.value;
2646
- const total = docs.reduce((sum, doc) => sum + computeLightSeoScore(doc.data ?? {}), 0);
2647
- avgSeoScore = Math.round(total / docs.length);
2648
- }
3097
+ const avgSeoScore = seoScoreResult.status === 'fulfilled' ? seoScoreResult.value : 0;
2649
3098
  const recentDocs = recentResult.status === 'fulfilled'
2650
3099
  ? recentResult.value.map((doc) => ({
2651
3100
  id: doc.id,