@actuate-media/cms-core 0.26.0 → 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.
- 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/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +415 -5
- 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
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';
|
|
@@ -708,6 +709,41 @@ class ModelNotAvailableError extends Error {
|
|
|
708
709
|
this.model = model;
|
|
709
710
|
}
|
|
710
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
|
+
}
|
|
711
747
|
export function registerCMSRoutes(router) {
|
|
712
748
|
const rawDb = () => getDB();
|
|
713
749
|
const db = () => {
|
|
@@ -811,6 +847,14 @@ export function registerCMSRoutes(router) {
|
|
|
811
847
|
type: def?.type ?? 'page',
|
|
812
848
|
urlPrefix: def?.urlPrefix ?? slug,
|
|
813
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,
|
|
814
858
|
fieldCount: fieldSummary.length,
|
|
815
859
|
fields: fieldSummary,
|
|
816
860
|
};
|
|
@@ -1705,6 +1749,337 @@ export function registerCMSRoutes(router) {
|
|
|
1705
1749
|
}
|
|
1706
1750
|
});
|
|
1707
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
|
+
// ---------------------------------------------------------------------------
|
|
1708
2083
|
// Media routes
|
|
1709
2084
|
// ---------------------------------------------------------------------------
|
|
1710
2085
|
router.get('/media', async (request) => {
|
|
@@ -2578,6 +2953,36 @@ export function registerCMSRoutes(router) {
|
|
|
2578
2953
|
score += 25;
|
|
2579
2954
|
return score;
|
|
2580
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
|
+
}
|
|
2581
2986
|
router.get('/stats', async (request) => {
|
|
2582
2987
|
try {
|
|
2583
2988
|
if (!isSecretMissing()) {
|
|
@@ -2592,8 +2997,13 @@ export function registerCMSRoutes(router) {
|
|
|
2592
2997
|
catch {
|
|
2593
2998
|
return json({ data: EMPTY_STATS });
|
|
2594
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' });
|
|
2595
3005
|
const [docResult, mediaResult, userResult, recentResult, collGroupResult, statusGroupResult, formResult, seoDocsResult, webhookTotalResult, webhookActiveResult,] = await Promise.allSettled([
|
|
2596
|
-
safeCount(d.document,
|
|
3006
|
+
safeCount(d.document, contentWhere),
|
|
2597
3007
|
safeCount(d.media),
|
|
2598
3008
|
safeCount(d.user),
|
|
2599
3009
|
safeFindMany(d.document, {
|
|
@@ -2603,7 +3013,7 @@ export function registerCMSRoutes(router) {
|
|
|
2603
3013
|
// -> Prisma Postgres path that saves ~80 ms (one full network
|
|
2604
3014
|
// RTT) on every dashboard load.
|
|
2605
3015
|
relationLoadStrategy: 'join',
|
|
2606
|
-
where:
|
|
3016
|
+
where: contentWhere,
|
|
2607
3017
|
orderBy: { updatedAt: 'desc' },
|
|
2608
3018
|
take: 20,
|
|
2609
3019
|
select: {
|
|
@@ -2616,11 +3026,11 @@ export function registerCMSRoutes(router) {
|
|
|
2616
3026
|
createdBy: { select: { name: true, email: true } },
|
|
2617
3027
|
},
|
|
2618
3028
|
}),
|
|
2619
|
-
safeGroupBy(d.document, ['collection'],
|
|
2620
|
-
safeGroupBy(d.document, ['status'],
|
|
3029
|
+
safeGroupBy(d.document, ['collection'], contentWhere),
|
|
3030
|
+
safeGroupBy(d.document, ['status'], contentWhere),
|
|
2621
3031
|
safeCount(d.formSubmission),
|
|
2622
3032
|
safeFindMany(d.document, {
|
|
2623
|
-
where:
|
|
3033
|
+
where: publishedContentWhere,
|
|
2624
3034
|
select: { data: true },
|
|
2625
3035
|
take: 200,
|
|
2626
3036
|
}),
|