@actuate-media/cms-core 0.25.1 → 0.27.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 (69) 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-routes.test.d.ts +2 -0
  12. package/dist/__tests__/api/stats-routes.test.d.ts.map +1 -0
  13. package/dist/__tests__/api/stats-routes.test.js +251 -0
  14. package/dist/__tests__/api/stats-routes.test.js.map +1 -0
  15. package/dist/__tests__/content-health/scanner.test.d.ts +2 -0
  16. package/dist/__tests__/content-health/scanner.test.d.ts.map +1 -0
  17. package/dist/__tests__/content-health/scanner.test.js +151 -0
  18. package/dist/__tests__/content-health/scanner.test.js.map +1 -0
  19. package/dist/__tests__/diagnostics/api-metrics.test.d.ts +2 -0
  20. package/dist/__tests__/diagnostics/api-metrics.test.d.ts.map +1 -0
  21. package/dist/__tests__/diagnostics/api-metrics.test.js +153 -0
  22. package/dist/__tests__/diagnostics/api-metrics.test.js.map +1 -0
  23. package/dist/api/handler-factory.d.ts.map +1 -1
  24. package/dist/api/handler-factory.js +31 -1
  25. package/dist/api/handler-factory.js.map +1 -1
  26. package/dist/api/handlers.d.ts.map +1 -1
  27. package/dist/api/handlers.js +678 -5
  28. package/dist/api/handlers.js.map +1 -1
  29. package/dist/config/types.d.ts +8 -0
  30. package/dist/config/types.d.ts.map +1 -1
  31. package/dist/content-health/cron.d.ts +67 -0
  32. package/dist/content-health/cron.d.ts.map +1 -0
  33. package/dist/content-health/cron.js +167 -0
  34. package/dist/content-health/cron.js.map +1 -0
  35. package/dist/content-health/scanner.d.ts +118 -0
  36. package/dist/content-health/scanner.d.ts.map +1 -0
  37. package/dist/content-health/scanner.js +263 -0
  38. package/dist/content-health/scanner.js.map +1 -0
  39. package/dist/diagnostics/api-metrics.d.ts +135 -0
  40. package/dist/diagnostics/api-metrics.d.ts.map +1 -0
  41. package/dist/diagnostics/api-metrics.js +374 -0
  42. package/dist/diagnostics/api-metrics.js.map +1 -0
  43. package/dist/sections/__tests__/sections.test.d.ts +2 -0
  44. package/dist/sections/__tests__/sections.test.d.ts.map +1 -0
  45. package/dist/sections/__tests__/sections.test.js +215 -0
  46. package/dist/sections/__tests__/sections.test.js.map +1 -0
  47. package/dist/sections/helpers.d.ts +62 -0
  48. package/dist/sections/helpers.d.ts.map +1 -0
  49. package/dist/sections/helpers.js +202 -0
  50. package/dist/sections/helpers.js.map +1 -0
  51. package/dist/sections/index.d.ts +13 -0
  52. package/dist/sections/index.d.ts.map +1 -0
  53. package/dist/sections/index.js +12 -0
  54. package/dist/sections/index.js.map +1 -0
  55. package/dist/sections/registry.d.ts +27 -0
  56. package/dist/sections/registry.d.ts.map +1 -0
  57. package/dist/sections/registry.js +349 -0
  58. package/dist/sections/registry.js.map +1 -0
  59. package/dist/sections/types.d.ts +127 -0
  60. package/dist/sections/types.d.ts.map +1 -0
  61. package/dist/sections/types.js +21 -0
  62. package/dist/sections/types.js.map +1 -0
  63. package/dist/sections/validate.d.ts +10 -0
  64. package/dist/sections/validate.d.ts.map +1 -0
  65. package/dist/sections/validate.js +92 -0
  66. package/dist/sections/validate.js.map +1 -0
  67. package/package.json +6 -1
  68. package/prisma/migrations/0007_dashboard_metrics/migration.sql +74 -0
  69. package/prisma/schema.prisma +180 -73
@@ -21,6 +21,7 @@ import { publishNotification } from '../realtime/notification-bus.js';
21
21
  import { createNotificationStreamResponse } from '../realtime/notification-stream.js';
22
22
  import { schedulingCronHandler } from '../scheduling/index.js';
23
23
  import { isAuthorizedCronRequest, processCleanup, processSeoScan } from '../cron/index.js';
24
+ import { processContentHealthScan } from '../content-health/cron.js';
24
25
  import { verifyCaptcha, getCaptchaConfig } from '../security/captcha.js';
25
26
  import { checkForUpdates } from '../upgrade/version-check.js';
26
27
  import { createUpgradePR } from '../upgrade/upgrade-pr.js';
@@ -32,6 +33,7 @@ import { createSSEPresenceAdapter } from '../presence/index.js';
32
33
  import { BUILT_IN_TEMPLATES } from '../page-builder/templates.js';
33
34
  import { validateTree } from '../page-builder/validate.js';
34
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';
35
37
  import { getClientIp, isResolvedIp } from '../security/client-ip.js';
36
38
  import { safeFetch, SsrfBlockedError } from '../security/safe-fetch.js';
37
39
  import { redactSecrets } from '../security/redact.js';
@@ -707,6 +709,41 @@ class ModelNotAvailableError extends Error {
707
709
  this.model = model;
708
710
  }
709
711
  }
712
+ /**
713
+ * Resolve a collection definition from the runtime config, tolerating both
714
+ * the array and the record (keyed-by-slug) shapes consumers use.
715
+ */
716
+ function findCollectionDef(slug) {
717
+ const collections = getActuateConfig()?.collections;
718
+ if (Array.isArray(collections)) {
719
+ return collections.find((c) => c?.slug === slug);
720
+ }
721
+ if (collections && typeof collections === 'object') {
722
+ const rec = collections;
723
+ return rec[slug] ?? Object.values(rec).find((c) => c?.slug === slug);
724
+ }
725
+ return undefined;
726
+ }
727
+ /**
728
+ * True when `collection` declares a `sections` JSON field — the storage
729
+ * contract the flat Page Editor + section endpoints rely on. Guards against
730
+ * silently dropping section writes on a collection that never declared the
731
+ * field (the field-access layer strips undeclared keys).
732
+ */
733
+ function collectionSupportsSections(slug) {
734
+ const def = findCollectionDef(slug);
735
+ if (!def)
736
+ return false;
737
+ const fields = def.fields;
738
+ if (Array.isArray(fields)) {
739
+ return fields.some((f) => f?.name === 'sections' && f?.type === 'json');
740
+ }
741
+ if (fields && typeof fields === 'object') {
742
+ const f = fields.sections;
743
+ return f?.type === 'json';
744
+ }
745
+ return false;
746
+ }
710
747
  export function registerCMSRoutes(router) {
711
748
  const rawDb = () => getDB();
712
749
  const db = () => {
@@ -810,6 +847,14 @@ export function registerCMSRoutes(router) {
810
847
  type: def?.type ?? 'page',
811
848
  urlPrefix: def?.urlPrefix ?? slug,
812
849
  hidden: def?.admin?.hidden === true,
850
+ // Surface the admin metadata the Posts area needs to render
851
+ // type tabs / cards (description, icon, accent color, group).
852
+ // None are sensitive — they all already live on the public
853
+ // collection definition the admin client receives via props.
854
+ description: def?.admin?.description ?? null,
855
+ icon: def?.admin?.icon ?? null,
856
+ color: def?.admin?.color ?? null,
857
+ group: def?.admin?.group ?? null,
813
858
  fieldCount: fieldSummary.length,
814
859
  fields: fieldSummary,
815
860
  };
@@ -1704,6 +1749,337 @@ export function registerCMSRoutes(router) {
1704
1749
  }
1705
1750
  });
1706
1751
  // ---------------------------------------------------------------------------
1752
+ // Page section routes (flat Page Editor model)
1753
+ //
1754
+ // These operate on a page document's `data.sections` JSON array — the same
1755
+ // model the visual Page Editor reads/writes — so an AI agent driving the
1756
+ // CMS from Cursor/VS Code via MCP produces real, editable, publishable
1757
+ // sections rather than disposable markup. All mutations go through the
1758
+ // audited `updateDocument` path (versions, hooks, cache invalidation,
1759
+ // live-preview push) and reuse collection scope checks.
1760
+ //
1761
+ // Discovery + validation are deterministic (no LLM) and only need auth.
1762
+ // ---------------------------------------------------------------------------
1763
+ // GET /page-sections/types — discover registered section types + schemas.
1764
+ // Optional `?scope=page|post` filters to types usable on that surface so an
1765
+ // agent only builds sections that have a working renderer in that context.
1766
+ router.get('/page-sections/types', async (request) => {
1767
+ try {
1768
+ const auth = await requireAuth(request);
1769
+ if (auth.error)
1770
+ return auth.error;
1771
+ const scope = new URL(request.url).searchParams.get('scope');
1772
+ const data = scope === 'page' || scope === 'post'
1773
+ ? getSectionTypesForScope(scope)
1774
+ : SECTION_TYPE_LIST;
1775
+ return json({ data });
1776
+ }
1777
+ catch (err) {
1778
+ return internalError(err, 'list section types');
1779
+ }
1780
+ });
1781
+ // POST /page-sections/validate — validate section content against its
1782
+ // type schema. Body: { sectionType, content, settings? }.
1783
+ router.post('/page-sections/validate', async (request) => {
1784
+ try {
1785
+ const auth = await requireAuth(request);
1786
+ if (auth.error)
1787
+ return auth.error;
1788
+ const body = (await request.json().catch(() => null));
1789
+ const sectionType = typeof body?.sectionType === 'string' ? body.sectionType : '';
1790
+ if (!sectionType)
1791
+ return errorResponse('sectionType is required', 400);
1792
+ const content = body?.content && typeof body.content === 'object'
1793
+ ? body.content
1794
+ : {};
1795
+ const settings = body?.settings && typeof body.settings === 'object'
1796
+ ? body.settings
1797
+ : undefined;
1798
+ const result = validateSectionContent(sectionType, content, settings);
1799
+ return json({ data: result });
1800
+ }
1801
+ catch (err) {
1802
+ return internalError(err, 'validate section');
1803
+ }
1804
+ });
1805
+ // Shared: load a page document's sections, or a Response on error.
1806
+ const loadPageSections = async (collection, id, ctx) => {
1807
+ if (!collectionSupportsSections(collection)) {
1808
+ return errorResponse(`Collection "${collection}" does not declare a "sections" JSON field, so it cannot store page sections.`, 400);
1809
+ }
1810
+ const doc = await getDocument(collection, id, ctx);
1811
+ if (!doc)
1812
+ return errorResponse('Page not found', 404);
1813
+ const data = doc.data ?? {};
1814
+ return { sections: coerceSections(data.sections) };
1815
+ };
1816
+ // Shared: persist a mutated section array via the audited update path.
1817
+ const persistPageSections = async (collection, id, sections, ctx, userId) => {
1818
+ const doc = await updateDocument(collection, id, { sections }, ctx);
1819
+ await logEvent({
1820
+ event: 'document_updated',
1821
+ userId,
1822
+ details: { collection, documentId: id, action: 'page_sections_updated' },
1823
+ });
1824
+ notifyPreviewUpdate(collection, id, {
1825
+ values: doc.data,
1826
+ status: doc.status,
1827
+ updatedAt: doc.updatedAt?.toISOString?.(),
1828
+ });
1829
+ const data = doc.data ?? {};
1830
+ return coerceSections(data.sections);
1831
+ };
1832
+ // GET /page-sections/:collection/:id — list a page's sections.
1833
+ router.get('/page-sections/:collection/:id', async (request, params) => {
1834
+ try {
1835
+ const auth = await requireAuth(request);
1836
+ if (auth.error)
1837
+ return auth.error;
1838
+ const scopeErr = requireCollectionScope(auth.session, params.collection, 'read');
1839
+ if (scopeErr)
1840
+ return scopeErr;
1841
+ const ctx = buildActionContext(auth.session, db());
1842
+ const loaded = await loadPageSections(params.collection, params.id, ctx);
1843
+ if (loaded instanceof Response)
1844
+ return loaded;
1845
+ return json({ data: loaded.sections });
1846
+ }
1847
+ catch (err) {
1848
+ return internalError(err, 'list page sections');
1849
+ }
1850
+ });
1851
+ // POST /page-sections/:collection/:id — add a section.
1852
+ // Body: { sectionType, name?, internalLabel?, visible?, content?, settings?, position? }.
1853
+ router.post('/page-sections/:collection/:id', async (request, params) => {
1854
+ try {
1855
+ const auth = await requireAuth(request);
1856
+ if (auth.error)
1857
+ return auth.error;
1858
+ const scopeErr = requireCollectionScope(auth.session, params.collection, 'update');
1859
+ if (scopeErr)
1860
+ return scopeErr;
1861
+ const body = (await request.json().catch(() => null));
1862
+ const sectionType = typeof body?.sectionType === 'string' ? body.sectionType : '';
1863
+ if (!getSectionType(sectionType)) {
1864
+ return errorResponse(`Unknown section type "${sectionType}". Call GET /page-sections/types for valid ids.`, 400);
1865
+ }
1866
+ const ctx = buildActionContext(auth.session, db());
1867
+ const loaded = await loadPageSections(params.collection, params.id, ctx);
1868
+ if (loaded instanceof Response)
1869
+ return loaded;
1870
+ const section = buildSection(sectionType, {
1871
+ name: typeof body?.name === 'string' ? body.name : undefined,
1872
+ internalLabel: typeof body?.internalLabel === 'string' ? body.internalLabel : undefined,
1873
+ visible: body?.visible !== false,
1874
+ content: body?.content && typeof body.content === 'object'
1875
+ ? body.content
1876
+ : undefined,
1877
+ settings: body?.settings && typeof body.settings === 'object'
1878
+ ? body.settings
1879
+ : undefined,
1880
+ });
1881
+ const position = typeof body?.position === 'number' ? body.position : undefined;
1882
+ const next = addSectionToList(loaded.sections, section, position);
1883
+ const saved = await persistPageSections(params.collection, params.id, next, ctx, auth.session.userId);
1884
+ const validation = validateSectionContent(section.sectionType, section.content, section.settings);
1885
+ return json({ data: { section, sections: saved, validation } }, 201);
1886
+ }
1887
+ catch (err) {
1888
+ return internalError(err, 'add page section');
1889
+ }
1890
+ });
1891
+ // POST /page-sections/:collection/:id/reorder — reorder by id list.
1892
+ // Registered BEFORE the :sectionId routes so "reorder" isn't captured as a
1893
+ // section id (the router matches in registration order).
1894
+ router.post('/page-sections/:collection/:id/reorder', async (request, params) => {
1895
+ try {
1896
+ const auth = await requireAuth(request);
1897
+ if (auth.error)
1898
+ return auth.error;
1899
+ const scopeErr = requireCollectionScope(auth.session, params.collection, 'update');
1900
+ if (scopeErr)
1901
+ return scopeErr;
1902
+ const body = (await request.json().catch(() => null));
1903
+ const orderedIds = Array.isArray(body?.orderedIds)
1904
+ ? body.orderedIds.filter((x) => typeof x === 'string')
1905
+ : null;
1906
+ if (!orderedIds)
1907
+ return errorResponse('orderedIds (string[]) is required', 400);
1908
+ const ctx = buildActionContext(auth.session, db());
1909
+ const loaded = await loadPageSections(params.collection, params.id, ctx);
1910
+ if (loaded instanceof Response)
1911
+ return loaded;
1912
+ const next = reorderSectionList(loaded.sections, orderedIds);
1913
+ const saved = await persistPageSections(params.collection, params.id, next, ctx, auth.session.userId);
1914
+ return json({ data: saved });
1915
+ }
1916
+ catch (err) {
1917
+ return internalError(err, 'reorder page sections');
1918
+ }
1919
+ });
1920
+ // PATCH /page-sections/:collection/:id/:sectionId — update a section.
1921
+ // Body: { content?, settings?, name?, internalLabel?, visible? }.
1922
+ router.patch('/page-sections/:collection/:id/:sectionId', async (request, params) => {
1923
+ try {
1924
+ const auth = await requireAuth(request);
1925
+ if (auth.error)
1926
+ return auth.error;
1927
+ const scopeErr = requireCollectionScope(auth.session, params.collection, 'update');
1928
+ if (scopeErr)
1929
+ return scopeErr;
1930
+ const body = (await request.json().catch(() => null));
1931
+ const ctx = buildActionContext(auth.session, db());
1932
+ const loaded = await loadPageSections(params.collection, params.id, ctx);
1933
+ if (loaded instanceof Response)
1934
+ return loaded;
1935
+ const sectionId = params.sectionId;
1936
+ if (!loaded.sections.some((s) => s.id === sectionId)) {
1937
+ return errorResponse(`Section "${sectionId}" not found on this page.`, 404);
1938
+ }
1939
+ let next = loaded.sections;
1940
+ if (body?.content && typeof body.content === 'object') {
1941
+ next = updateSectionContentInList(next, sectionId, body.content);
1942
+ }
1943
+ if (body?.settings && typeof body.settings === 'object') {
1944
+ next = updateSectionSettingsInList(next, sectionId, body.settings);
1945
+ }
1946
+ const meta = {};
1947
+ if (typeof body?.name === 'string')
1948
+ meta.name = body.name;
1949
+ if (typeof body?.internalLabel === 'string')
1950
+ meta.internalLabel = body.internalLabel;
1951
+ if (Object.keys(meta).length > 0)
1952
+ next = updateSectionMetaInList(next, sectionId, meta);
1953
+ if (typeof body?.visible === 'boolean') {
1954
+ next = setSectionVisibility(next, sectionId, body.visible);
1955
+ }
1956
+ const saved = await persistPageSections(params.collection, params.id, next, ctx, auth.session.userId);
1957
+ const updated = saved.find((s) => s.id === sectionId);
1958
+ const validation = updated
1959
+ ? validateSectionContent(updated.sectionType, updated.content, updated.settings)
1960
+ : undefined;
1961
+ return json({ data: { section: updated, sections: saved, validation } });
1962
+ }
1963
+ catch (err) {
1964
+ return internalError(err, 'update page section');
1965
+ }
1966
+ });
1967
+ // DELETE /page-sections/:collection/:id/:sectionId — remove a section.
1968
+ router.delete('/page-sections/:collection/:id/:sectionId', async (request, params) => {
1969
+ try {
1970
+ const auth = await requireAuth(request);
1971
+ if (auth.error)
1972
+ return auth.error;
1973
+ const scopeErr = requireCollectionScope(auth.session, params.collection, 'update');
1974
+ if (scopeErr)
1975
+ return scopeErr;
1976
+ const ctx = buildActionContext(auth.session, db());
1977
+ const loaded = await loadPageSections(params.collection, params.id, ctx);
1978
+ if (loaded instanceof Response)
1979
+ return loaded;
1980
+ const sectionId = params.sectionId;
1981
+ if (!loaded.sections.some((s) => s.id === sectionId)) {
1982
+ return errorResponse(`Section "${sectionId}" not found on this page.`, 404);
1983
+ }
1984
+ const next = removeSectionFromList(loaded.sections, sectionId);
1985
+ const saved = await persistPageSections(params.collection, params.id, next, ctx, auth.session.userId);
1986
+ return json({ data: { sections: saved } });
1987
+ }
1988
+ catch (err) {
1989
+ return internalError(err, 'remove page section');
1990
+ }
1991
+ });
1992
+ // ---------------------------------------------------------------------------
1993
+ // Post template routes
1994
+ //
1995
+ // A post type's template is a single document in the hidden
1996
+ // `post-templates` collection, matched by `slug` == the post-type slug. It
1997
+ // stores the design-driven header layout (`data.header`) and the default
1998
+ // section list (`data.sections`) new posts inherit. Lets an external AI
1999
+ // agent (Cursor/VS Code) read a type's template and build it from
2000
+ // scope=post section types discovered via `/page-sections/types?scope=post`.
2001
+ // ---------------------------------------------------------------------------
2002
+ const POST_TEMPLATES_COLLECTION = 'post-templates';
2003
+ const findPostTemplateDoc = async (postType, ctx) => {
2004
+ const res = await listDocuments({ collection: POST_TEMPLATES_COLLECTION, pageSize: 200 }, ctx);
2005
+ const docs = res.docs ?? [];
2006
+ return (docs.find((d) => (d.slug ?? d.data?.slug) === postType) ?? null);
2007
+ };
2008
+ const templateResponse = (postType, doc) => {
2009
+ if (!doc) {
2010
+ return { postType, docId: null, header: defaultPostHeader(), sections: [] };
2011
+ }
2012
+ const data = doc.data ?? {};
2013
+ return {
2014
+ postType,
2015
+ docId: doc.id,
2016
+ header: coercePostHeader(data.header),
2017
+ sections: coerceSections(data.sections),
2018
+ };
2019
+ };
2020
+ // GET /post-templates/:type — read a post type's template (header +
2021
+ // default sections). Returns an unsaved default when none exists yet.
2022
+ router.get('/post-templates/:type', async (request, params) => {
2023
+ try {
2024
+ const auth = await requireAuth(request);
2025
+ if (auth.error)
2026
+ return auth.error;
2027
+ const scopeErr = requireCollectionScope(auth.session, POST_TEMPLATES_COLLECTION, 'read');
2028
+ if (scopeErr)
2029
+ return scopeErr;
2030
+ const ctx = buildActionContext(auth.session, db());
2031
+ const doc = await findPostTemplateDoc(params.type, ctx);
2032
+ return json({ data: templateResponse(params.type, doc) });
2033
+ }
2034
+ catch (err) {
2035
+ return internalError(err, 'get post template');
2036
+ }
2037
+ });
2038
+ // PUT /post-templates/:type — create or replace a post type's template.
2039
+ // Body: { header?, sections? }. Header is coerced to a full PostHeaderConfig
2040
+ // and sections are coerced (unknown section types dropped) so the stored
2041
+ // template always renders.
2042
+ router.put('/post-templates/:type', async (request, params) => {
2043
+ try {
2044
+ const auth = await requireAuth(request);
2045
+ if (auth.error)
2046
+ return auth.error;
2047
+ const scopeErr = requireCollectionScope(auth.session, POST_TEMPLATES_COLLECTION, 'update');
2048
+ if (scopeErr)
2049
+ return scopeErr;
2050
+ const body = (await request.json().catch(() => null));
2051
+ const header = coercePostHeader(body?.header);
2052
+ const sections = coerceSections(body?.sections);
2053
+ const ctx = buildActionContext(auth.session, db());
2054
+ const existing = await findPostTemplateDoc(params.type, ctx);
2055
+ const data = {
2056
+ title: `${params.type} template`,
2057
+ slug: params.type,
2058
+ postType: params.type,
2059
+ header,
2060
+ sections,
2061
+ status: 'PUBLISHED',
2062
+ };
2063
+ const doc = existing
2064
+ ? await updateDocument(POST_TEMPLATES_COLLECTION, existing.id, data, ctx)
2065
+ : await createDocument(POST_TEMPLATES_COLLECTION, data, ctx);
2066
+ await logEvent({
2067
+ event: existing ? 'document_updated' : 'document_created',
2068
+ userId: auth.session.userId,
2069
+ details: {
2070
+ collection: POST_TEMPLATES_COLLECTION,
2071
+ documentId: doc.id,
2072
+ action: 'post_template_saved',
2073
+ postType: params.type,
2074
+ },
2075
+ });
2076
+ return json({ data: templateResponse(params.type, doc) });
2077
+ }
2078
+ catch (err) {
2079
+ return internalError(err, 'save post template');
2080
+ }
2081
+ });
2082
+ // ---------------------------------------------------------------------------
1707
2083
  // Media routes
1708
2084
  // ---------------------------------------------------------------------------
1709
2085
  router.get('/media', async (request) => {
@@ -2520,6 +2896,31 @@ export function registerCMSRoutes(router) {
2520
2896
  statusCounts: {},
2521
2897
  recentDocuments: [],
2522
2898
  };
2899
+ const EMPTY_DELIVERY_STATS = {
2900
+ hasData: false,
2901
+ requests24h: 0,
2902
+ requestsPrev24h: 0,
2903
+ deltaPercent: null,
2904
+ errorRate: 0,
2905
+ errorCount: 0,
2906
+ avgLatencyMs: 0,
2907
+ p95LatencyMs: 0,
2908
+ maxLatencyMs: 0,
2909
+ webhookCount: 0,
2910
+ webhookActiveCount: 0,
2911
+ };
2912
+ const EMPTY_CONTENT_HEALTH = {
2913
+ score: 100,
2914
+ label: 'Good',
2915
+ counts: {
2916
+ MISSING_META_DESCRIPTION: 0,
2917
+ MISSING_ALT_TEXT: 0,
2918
+ BROKEN_INTERNAL_LINK: 0,
2919
+ OUTDATED_CONTENT: 0,
2920
+ },
2921
+ totalIssues: 0,
2922
+ lastScanAt: null,
2923
+ };
2523
2924
  async function safeGroupBy(model, by, where) {
2524
2925
  if (!model || typeof model !== 'object')
2525
2926
  return [];
@@ -2552,6 +2953,36 @@ export function registerCMSRoutes(router) {
2552
2953
  score += 25;
2553
2954
  return score;
2554
2955
  }
2956
+ /**
2957
+ * Collection slugs that are written by the benchmark suite and a few
2958
+ * other internal/test fixtures. Documents in these collections are
2959
+ * never editor-facing content and should never appear in the
2960
+ * dashboard's stats, counts, or recent-activity feed.
2961
+ *
2962
+ * This list is the fallback when the consumer ships collection
2963
+ * definitions only to the admin client (e.g. `apps/dev`) and
2964
+ * `getActuateConfig()?.collections` is therefore empty on the
2965
+ * server. Production consumers normally pass `collections` to
2966
+ * `handleActuateAPI({ config })`, in which case the registered
2967
+ * slugs become the allowlist and this denylist is unused.
2968
+ */
2969
+ const INTERNAL_COLLECTION_SLUGS = ['benchmarks', 'bench'];
2970
+ /**
2971
+ * Build the Prisma `where` clause for "user-facing content".
2972
+ *
2973
+ * - If the CMS config registered collections, restrict to those
2974
+ * slugs — anything else is orphan/test data.
2975
+ * - Otherwise fall back to excluding the internal denylist.
2976
+ *
2977
+ * Always excludes soft-deleted rows. Callers can spread additional
2978
+ * filters on top (`status: 'PUBLISHED'`, etc.).
2979
+ */
2980
+ function userContentWhere(extra = {}) {
2981
+ const cfg = getActuateConfig();
2982
+ const registered = Object.keys(cfg?.collections ?? {});
2983
+ const collectionFilter = registered.length > 0 ? { in: registered } : { notIn: [...INTERNAL_COLLECTION_SLUGS] };
2984
+ return { deletedAt: null, collection: collectionFilter, ...extra };
2985
+ }
2555
2986
  router.get('/stats', async (request) => {
2556
2987
  try {
2557
2988
  if (!isSecretMissing()) {
@@ -2566,8 +2997,13 @@ export function registerCMSRoutes(router) {
2566
2997
  catch {
2567
2998
  return json({ data: EMPTY_STATS });
2568
2999
  }
3000
+ // `userContentWhere()` filters out benchmark / orphan / internal
3001
+ // collections so the dashboard surfaces editor content only. See
3002
+ // the helper above for the allowlist-vs-denylist behaviour.
3003
+ const contentWhere = userContentWhere();
3004
+ const publishedContentWhere = userContentWhere({ status: 'PUBLISHED' });
2569
3005
  const [docResult, mediaResult, userResult, recentResult, collGroupResult, statusGroupResult, formResult, seoDocsResult, webhookTotalResult, webhookActiveResult,] = await Promise.allSettled([
2570
- safeCount(d.document, { deletedAt: null }),
3006
+ safeCount(d.document, contentWhere),
2571
3007
  safeCount(d.media),
2572
3008
  safeCount(d.user),
2573
3009
  safeFindMany(d.document, {
@@ -2577,7 +3013,7 @@ export function registerCMSRoutes(router) {
2577
3013
  // -> Prisma Postgres path that saves ~80 ms (one full network
2578
3014
  // RTT) on every dashboard load.
2579
3015
  relationLoadStrategy: 'join',
2580
- where: { deletedAt: null },
3016
+ where: contentWhere,
2581
3017
  orderBy: { updatedAt: 'desc' },
2582
3018
  take: 20,
2583
3019
  select: {
@@ -2590,11 +3026,11 @@ export function registerCMSRoutes(router) {
2590
3026
  createdBy: { select: { name: true, email: true } },
2591
3027
  },
2592
3028
  }),
2593
- safeGroupBy(d.document, ['collection'], { deletedAt: null }),
2594
- safeGroupBy(d.document, ['status'], { deletedAt: null }),
3029
+ safeGroupBy(d.document, ['collection'], contentWhere),
3030
+ safeGroupBy(d.document, ['status'], contentWhere),
2595
3031
  safeCount(d.formSubmission),
2596
3032
  safeFindMany(d.document, {
2597
- where: { deletedAt: null, status: 'PUBLISHED' },
3033
+ where: publishedContentWhere,
2598
3034
  select: { data: true },
2599
3035
  take: 200,
2600
3036
  }),
@@ -2650,6 +3086,227 @@ export function registerCMSRoutes(router) {
2650
3086
  }
2651
3087
  });
2652
3088
  // ---------------------------------------------------------------------------
3089
+ // Content Health — backs the dashboard "Content Health" issue rows
3090
+ // ---------------------------------------------------------------------------
3091
+ //
3092
+ // Aggregates rows from `actuate_content_issues` (populated nightly by
3093
+ // /cron/content-health) by `type`, returning the totals the dashboard
3094
+ // surfaces. We also compute an overall content-health score so the
3095
+ // big number + progress bar stay consistent with the per-row counts.
3096
+ //
3097
+ // Score model:
3098
+ // - Start at 100.
3099
+ // - Subtract 5 per missing-meta-description issue.
3100
+ // - Subtract 2 per missing-alt image.
3101
+ // - Subtract 10 per broken internal link.
3102
+ // - Subtract 3 per outdated document.
3103
+ // Floor at 0. The weights reflect SEO impact (broken links and
3104
+ // missing meta hit hardest) and are kept here, not in the DB, so we
3105
+ // can tune them without a migration.
3106
+ router.get('/stats/content-health', async (request) => {
3107
+ try {
3108
+ if (!isSecretMissing()) {
3109
+ const auth = await requireAuth(request);
3110
+ if (auth.error)
3111
+ return auth.error;
3112
+ }
3113
+ let d;
3114
+ try {
3115
+ d = db();
3116
+ }
3117
+ catch {
3118
+ return json({ data: EMPTY_CONTENT_HEALTH });
3119
+ }
3120
+ if (!d?.contentIssue) {
3121
+ return json({ data: EMPTY_CONTENT_HEALTH });
3122
+ }
3123
+ let rows = [];
3124
+ try {
3125
+ rows = await d.contentIssue.groupBy({
3126
+ by: ['type'],
3127
+ _sum: { count: true },
3128
+ });
3129
+ }
3130
+ catch {
3131
+ return json({ data: EMPTY_CONTENT_HEALTH });
3132
+ }
3133
+ const counts = {
3134
+ MISSING_META_DESCRIPTION: 0,
3135
+ MISSING_ALT_TEXT: 0,
3136
+ BROKEN_INTERNAL_LINK: 0,
3137
+ OUTDATED_CONTENT: 0,
3138
+ };
3139
+ for (const r of rows) {
3140
+ counts[r.type] = r._sum.count ?? 0;
3141
+ }
3142
+ // Read-out via locals so TS narrows away the Record<string,..>
3143
+ // indexed-access undefined union — we know the keys are present
3144
+ // because we initialised them above.
3145
+ const cMeta = counts['MISSING_META_DESCRIPTION'] ?? 0;
3146
+ const cAlt = counts['MISSING_ALT_TEXT'] ?? 0;
3147
+ const cLinks = counts['BROKEN_INTERNAL_LINK'] ?? 0;
3148
+ const cOutdated = counts['OUTDATED_CONTENT'] ?? 0;
3149
+ // Penalty model — see comment above.
3150
+ let score = 100;
3151
+ score -= cMeta * 5;
3152
+ score -= cAlt * 2;
3153
+ score -= cLinks * 10;
3154
+ score -= cOutdated * 3;
3155
+ score = Math.max(0, Math.min(100, score));
3156
+ // Last scan timestamp — useful for the dashboard to display
3157
+ // "Last scanned X minutes ago" so editors don't think the
3158
+ // numbers are live.
3159
+ let lastScanAt = null;
3160
+ try {
3161
+ const latest = await d.contentIssue.findFirst({
3162
+ orderBy: { lastSeenAt: 'desc' },
3163
+ select: { lastSeenAt: true },
3164
+ });
3165
+ lastScanAt = latest?.lastSeenAt ?? null;
3166
+ }
3167
+ catch {
3168
+ /* ignore — empty table or stale schema */
3169
+ }
3170
+ return json({
3171
+ data: {
3172
+ score,
3173
+ label: score >= 70 ? 'Good' : score >= 40 ? 'Fair' : 'Poor',
3174
+ counts,
3175
+ totalIssues: cMeta + cAlt + cLinks + cOutdated,
3176
+ lastScanAt: lastScanAt?.toISOString() ?? null,
3177
+ },
3178
+ });
3179
+ }
3180
+ catch {
3181
+ return json({ data: EMPTY_CONTENT_HEALTH });
3182
+ }
3183
+ });
3184
+ // ---------------------------------------------------------------------------
3185
+ // Delivery API metrics — backs the dashboard "Delivery API" tiles
3186
+ // ---------------------------------------------------------------------------
3187
+ //
3188
+ // Reads from `actuate_api_request_metrics` (per-minute pre-aggregated
3189
+ // counters populated by `recordApiRequest` in handler-factory.ts) and
3190
+ // folds the last 24h into the four scalars the dashboard surfaces:
3191
+ //
3192
+ // - requests24h, requestsPrev24h (delta = "+12% vs yesterday")
3193
+ // - errorRate (4xx + 5xx as fraction of total)
3194
+ // - avgLatencyMs, p95LatencyMs (latter via histogram merge)
3195
+ // - webhooks (total + active count)
3196
+ //
3197
+ // The endpoint returns a complete, never-undefined object even when
3198
+ // the metrics table is empty — the dashboard distinguishes
3199
+ // "no traffic yet" from "table missing" via the boolean `hasData`
3200
+ // field so it can render an empty state without an extra round-trip.
3201
+ router.get('/stats/api-delivery', async (request) => {
3202
+ try {
3203
+ if (!isSecretMissing()) {
3204
+ const auth = await requireAuth(request);
3205
+ if (auth.error)
3206
+ return auth.error;
3207
+ }
3208
+ let d;
3209
+ try {
3210
+ d = db();
3211
+ }
3212
+ catch {
3213
+ return json({ data: EMPTY_DELIVERY_STATS });
3214
+ }
3215
+ if (!d?.apiRequestMetric) {
3216
+ return json({ data: EMPTY_DELIVERY_STATS });
3217
+ }
3218
+ const now = new Date();
3219
+ const since24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
3220
+ const sincePrev24h = new Date(now.getTime() - 48 * 60 * 60 * 1000);
3221
+ const [currentResult, prevResult, webhookTotalResult, webhookActiveResult] = await Promise.allSettled([
3222
+ safeFindMany(d.apiRequestMetric, {
3223
+ where: { bucketStart: { gte: since24h } },
3224
+ select: {
3225
+ statusBucket: true,
3226
+ count: true,
3227
+ sumLatencyMs: true,
3228
+ maxLatencyMs: true,
3229
+ latencyHistogram: true,
3230
+ },
3231
+ }),
3232
+ safeFindMany(d.apiRequestMetric, {
3233
+ where: { bucketStart: { gte: sincePrev24h, lt: since24h } },
3234
+ select: { count: true },
3235
+ }),
3236
+ safeCount(d.webhookEndpoint),
3237
+ safeCount(d.webhookEndpoint, { active: true }),
3238
+ ]);
3239
+ const currentRows = currentResult.status === 'fulfilled' ? currentResult.value : [];
3240
+ const prevRows = prevResult.status === 'fulfilled' ? prevResult.value : [];
3241
+ let total = 0;
3242
+ let errorTotal = 0; // 4xx + 5xx
3243
+ let sumLatency = 0;
3244
+ let maxLatency = 0;
3245
+ // Histogram merge for p95 — fixed 12-bucket array (see
3246
+ // diagnostics/api-metrics.ts `HISTOGRAM_LENGTH`).
3247
+ const mergedHistogram = new Array(12).fill(0);
3248
+ for (const row of currentRows) {
3249
+ total += row.count;
3250
+ if (row.statusBucket === '4xx' || row.statusBucket === '5xx')
3251
+ errorTotal += row.count;
3252
+ sumLatency += row.sumLatencyMs;
3253
+ if (row.maxLatencyMs > maxLatency)
3254
+ maxLatency = row.maxLatencyMs;
3255
+ const h = row.latencyHistogram;
3256
+ if (Array.isArray(h)) {
3257
+ for (let i = 0; i < mergedHistogram.length; i++) {
3258
+ const v = h[i];
3259
+ const current = mergedHistogram[i] ?? 0;
3260
+ if (typeof v === 'number')
3261
+ mergedHistogram[i] = current + v;
3262
+ }
3263
+ }
3264
+ }
3265
+ const prevTotal = prevRows.reduce((s, r) => s + r.count, 0);
3266
+ const delta = prevTotal === 0 ? null : Math.round(((total - prevTotal) / prevTotal) * 1000) / 10; // 1-decimal %
3267
+ const errorRate = total === 0 ? 0 : errorTotal / total;
3268
+ const avgLatency = total === 0 ? 0 : sumLatency / total;
3269
+ // p95: walk the merged histogram cumulatively, return the upper
3270
+ // bound of the bucket that crosses the 0.95 watermark.
3271
+ const LATENCY_BUCKET_UPPER = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024];
3272
+ let p95Latency = 0;
3273
+ if (total > 0) {
3274
+ const target = total * 0.95;
3275
+ let acc = 0;
3276
+ for (let i = 0; i < mergedHistogram.length; i++) {
3277
+ acc += mergedHistogram[i] ?? 0;
3278
+ if (acc >= target) {
3279
+ p95Latency =
3280
+ i < LATENCY_BUCKET_UPPER.length
3281
+ ? LATENCY_BUCKET_UPPER[i]
3282
+ : LATENCY_BUCKET_UPPER[LATENCY_BUCKET_UPPER.length - 1] * 2;
3283
+ break;
3284
+ }
3285
+ }
3286
+ }
3287
+ const webhookTotal = webhookTotalResult.status === 'fulfilled' ? webhookTotalResult.value : 0;
3288
+ const webhookActive = webhookActiveResult.status === 'fulfilled' ? webhookActiveResult.value : 0;
3289
+ return json({
3290
+ data: {
3291
+ hasData: total > 0,
3292
+ requests24h: total,
3293
+ requestsPrev24h: prevTotal,
3294
+ deltaPercent: delta,
3295
+ errorRate: Math.round(errorRate * 10000) / 10000, // 4-dec precision
3296
+ errorCount: errorTotal,
3297
+ avgLatencyMs: Math.round(avgLatency * 10) / 10,
3298
+ p95LatencyMs: p95Latency,
3299
+ maxLatencyMs: Math.round(maxLatency * 10) / 10,
3300
+ webhookCount: webhookTotal,
3301
+ webhookActiveCount: webhookActive,
3302
+ },
3303
+ });
3304
+ }
3305
+ catch {
3306
+ return json({ data: EMPTY_DELIVERY_STATS });
3307
+ }
3308
+ });
3309
+ // ---------------------------------------------------------------------------
2653
3310
  // Global search
2654
3311
  // ---------------------------------------------------------------------------
2655
3312
  router.get('/search/global', async (request) => {
@@ -5420,6 +6077,22 @@ export function registerCMSRoutes(router) {
5420
6077
  };
5421
6078
  router.get('/cron/seo-scan', cronSeoScan);
5422
6079
  router.post('/cron/seo-scan', cronSeoScan);
6080
+ const cronContentHealth = async (request) => {
6081
+ try {
6082
+ if (!isAuthorizedCronRequest(request.headers.get('authorization'))) {
6083
+ return errorResponse('Unauthorized', 401);
6084
+ }
6085
+ const config = getActuateConfig();
6086
+ const siteOrigin = config?.site?.url ?? config?.seo?.siteUrl ?? process.env.NEXT_PUBLIC_SITE_URL ?? null;
6087
+ const result = await processContentHealthScan(db(), { siteOrigin });
6088
+ return json({ data: result });
6089
+ }
6090
+ catch (err) {
6091
+ return internalError(err, 'cron content-health');
6092
+ }
6093
+ };
6094
+ router.get('/cron/content-health', cronContentHealth);
6095
+ router.post('/cron/content-health', cronContentHealth);
5423
6096
  router.get('/scheduling/calendar', async (request) => {
5424
6097
  try {
5425
6098
  const auth = await requireAuth(request);