@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.
- package/dist/__tests__/api/collections-discovery.test.js +47 -0
- package/dist/__tests__/api/collections-discovery.test.js.map +1 -1
- package/dist/__tests__/api/page-sections-routes.test.d.ts +2 -0
- package/dist/__tests__/api/page-sections-routes.test.d.ts.map +1 -0
- package/dist/__tests__/api/page-sections-routes.test.js +271 -0
- package/dist/__tests__/api/page-sections-routes.test.js.map +1 -0
- package/dist/__tests__/api/stats-collection-filter.test.d.ts +2 -0
- package/dist/__tests__/api/stats-collection-filter.test.d.ts.map +1 -0
- package/dist/__tests__/api/stats-collection-filter.test.js +190 -0
- package/dist/__tests__/api/stats-collection-filter.test.js.map +1 -0
- package/dist/__tests__/api/stats-seo-cache.test.d.ts +2 -0
- package/dist/__tests__/api/stats-seo-cache.test.d.ts.map +1 -0
- package/dist/__tests__/api/stats-seo-cache.test.js +96 -0
- package/dist/__tests__/api/stats-seo-cache.test.js.map +1 -0
- package/dist/api/handlers.d.ts +2 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +465 -16
- package/dist/api/handlers.js.map +1 -1
- package/dist/config/types.d.ts +8 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/sections/__tests__/sections.test.d.ts +2 -0
- package/dist/sections/__tests__/sections.test.d.ts.map +1 -0
- package/dist/sections/__tests__/sections.test.js +215 -0
- package/dist/sections/__tests__/sections.test.js.map +1 -0
- package/dist/sections/helpers.d.ts +62 -0
- package/dist/sections/helpers.d.ts.map +1 -0
- package/dist/sections/helpers.js +202 -0
- package/dist/sections/helpers.js.map +1 -0
- package/dist/sections/index.d.ts +13 -0
- package/dist/sections/index.d.ts.map +1 -0
- package/dist/sections/index.js +12 -0
- package/dist/sections/index.js.map +1 -0
- package/dist/sections/registry.d.ts +27 -0
- package/dist/sections/registry.d.ts.map +1 -0
- package/dist/sections/registry.js +349 -0
- package/dist/sections/registry.js.map +1 -0
- package/dist/sections/types.d.ts +127 -0
- package/dist/sections/types.d.ts.map +1 -0
- package/dist/sections/types.js +21 -0
- package/dist/sections/types.js.map +1 -0
- package/dist/sections/validate.d.ts +10 -0
- package/dist/sections/validate.d.ts.map +1 -0
- package/dist/sections/validate.js +92 -0
- package/dist/sections/validate.js.map +1 -0
- 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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"}
|
package/dist/api/handlers.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/api/handlers.js
CHANGED
|
@@ -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
|
-
|
|
2596
|
-
|
|
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:
|
|
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'],
|
|
2620
|
-
safeGroupBy(d.document, ['status'],
|
|
3075
|
+
safeGroupBy(d.document, ['collection'], contentWhere),
|
|
3076
|
+
safeGroupBy(d.document, ['status'], contentWhere),
|
|
2621
3077
|
safeCount(d.formSubmission),
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
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
|
-
|
|
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,
|