@actuate-media/cms-core 0.57.0 → 0.58.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/stats-seo-cache.test.js +1 -1
- package/dist/__tests__/api/stats-seo-cache.test.js.map +1 -1
- package/dist/__tests__/auth/oauth.test.js +3 -1
- package/dist/__tests__/auth/oauth.test.js.map +1 -1
- package/dist/__tests__/auth/reset.test.js +1 -0
- package/dist/__tests__/auth/reset.test.js.map +1 -1
- package/dist/__tests__/auth/user-admin.test.js +14 -12
- package/dist/__tests__/auth/user-admin.test.js.map +1 -1
- package/dist/__tests__/db/model-types-freshness.test.d.ts +2 -0
- package/dist/__tests__/db/model-types-freshness.test.d.ts.map +1 -0
- package/dist/__tests__/db/model-types-freshness.test.js +46 -0
- package/dist/__tests__/db/model-types-freshness.test.js.map +1 -0
- package/dist/__tests__/scheduling/scheduling.test.js +8 -6
- package/dist/__tests__/scheduling/scheduling.test.js.map +1 -1
- package/dist/__tests__/server-site.test.js +3 -1
- package/dist/__tests__/server-site.test.js.map +1 -1
- package/dist/__tests__/setup/index.test.js +21 -10
- package/dist/__tests__/setup/index.test.js.map +1 -1
- package/dist/actions.d.ts +2 -1
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +15 -8
- package/dist/actions.js.map +1 -1
- package/dist/api/guarded-db.d.ts +14 -0
- package/dist/api/guarded-db.d.ts.map +1 -0
- package/dist/api/guarded-db.js +58 -0
- package/dist/api/guarded-db.js.map +1 -0
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +2 -1
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts +5 -3
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +54 -13068
- package/dist/api/handlers.js.map +1 -1
- package/dist/api/route-helpers.d.ts +321 -0
- package/dist/api/route-helpers.d.ts.map +1 -0
- package/dist/api/route-helpers.js +1307 -0
- package/dist/api/route-helpers.js.map +1 -0
- package/dist/api/routes/ai-seo.d.ts +3 -0
- package/dist/api/routes/ai-seo.d.ts.map +1 -0
- package/dist/api/routes/ai-seo.js +377 -0
- package/dist/api/routes/ai-seo.js.map +1 -0
- package/dist/api/routes/ai-settings.d.ts +3 -0
- package/dist/api/routes/ai-settings.d.ts.map +1 -0
- package/dist/api/routes/ai-settings.js +764 -0
- package/dist/api/routes/ai-settings.js.map +1 -0
- package/dist/api/routes/auth.d.ts +3 -0
- package/dist/api/routes/auth.d.ts.map +1 -0
- package/dist/api/routes/auth.js +787 -0
- package/dist/api/routes/auth.js.map +1 -0
- package/dist/api/routes/collab.d.ts +3 -0
- package/dist/api/routes/collab.d.ts.map +1 -0
- package/dist/api/routes/collab.js +426 -0
- package/dist/api/routes/collab.js.map +1 -0
- package/dist/api/routes/dashboard.d.ts +7 -0
- package/dist/api/routes/dashboard.d.ts.map +1 -0
- package/dist/api/routes/dashboard.js +540 -0
- package/dist/api/routes/dashboard.js.map +1 -0
- package/dist/api/routes/documents.d.ts +3 -0
- package/dist/api/routes/documents.d.ts.map +1 -0
- package/dist/api/routes/documents.js +180 -0
- package/dist/api/routes/documents.js.map +1 -0
- package/dist/api/routes/folders.d.ts +3 -0
- package/dist/api/routes/folders.d.ts.map +1 -0
- package/dist/api/routes/folders.js +195 -0
- package/dist/api/routes/folders.js.map +1 -0
- package/dist/api/routes/forms.d.ts +3 -0
- package/dist/api/routes/forms.d.ts.map +1 -0
- package/dist/api/routes/forms.js +661 -0
- package/dist/api/routes/forms.js.map +1 -0
- package/dist/api/routes/globals.d.ts +3 -0
- package/dist/api/routes/globals.d.ts.map +1 -0
- package/dist/api/routes/globals.js +133 -0
- package/dist/api/routes/globals.js.map +1 -0
- package/dist/api/routes/media.d.ts +3 -0
- package/dist/api/routes/media.d.ts.map +1 -0
- package/dist/api/routes/media.js +521 -0
- package/dist/api/routes/media.js.map +1 -0
- package/dist/api/routes/meta.d.ts +3 -0
- package/dist/api/routes/meta.d.ts.map +1 -0
- package/dist/api/routes/meta.js +96 -0
- package/dist/api/routes/meta.js.map +1 -0
- package/dist/api/routes/page-templates.d.ts +3 -0
- package/dist/api/routes/page-templates.d.ts.map +1 -0
- package/dist/api/routes/page-templates.js +265 -0
- package/dist/api/routes/page-templates.js.map +1 -0
- package/dist/api/routes/presence.d.ts +3 -0
- package/dist/api/routes/presence.d.ts.map +1 -0
- package/dist/api/routes/presence.js +35 -0
- package/dist/api/routes/presence.js.map +1 -0
- package/dist/api/routes/preview.d.ts +3 -0
- package/dist/api/routes/preview.d.ts.map +1 -0
- package/dist/api/routes/preview.js +111 -0
- package/dist/api/routes/preview.js.map +1 -0
- package/dist/api/routes/redirects.d.ts +3 -0
- package/dist/api/routes/redirects.d.ts.map +1 -0
- package/dist/api/routes/redirects.js +790 -0
- package/dist/api/routes/redirects.js.map +1 -0
- package/dist/api/routes/saved-sections.d.ts +3 -0
- package/dist/api/routes/saved-sections.d.ts.map +1 -0
- package/dist/api/routes/saved-sections.js +1036 -0
- package/dist/api/routes/saved-sections.js.map +1 -0
- package/dist/api/routes/scheduling.d.ts +3 -0
- package/dist/api/routes/scheduling.d.ts.map +1 -0
- package/dist/api/routes/scheduling.js +373 -0
- package/dist/api/routes/scheduling.js.map +1 -0
- package/dist/api/routes/sections.d.ts +3 -0
- package/dist/api/routes/sections.d.ts.map +1 -0
- package/dist/api/routes/sections.js +500 -0
- package/dist/api/routes/sections.js.map +1 -0
- package/dist/api/routes/security-center.d.ts +3 -0
- package/dist/api/routes/security-center.d.ts.map +1 -0
- package/dist/api/routes/security-center.js +71 -0
- package/dist/api/routes/security-center.js.map +1 -0
- package/dist/api/routes/seo-public.d.ts +14 -0
- package/dist/api/routes/seo-public.d.ts.map +1 -0
- package/dist/api/routes/seo-public.js +930 -0
- package/dist/api/routes/seo-public.js.map +1 -0
- package/dist/api/routes/seo.d.ts +8 -0
- package/dist/api/routes/seo.d.ts.map +1 -0
- package/dist/api/routes/seo.js +848 -0
- package/dist/api/routes/seo.js.map +1 -0
- package/dist/api/routes/system.d.ts +3 -0
- package/dist/api/routes/system.d.ts.map +1 -0
- package/dist/api/routes/system.js +340 -0
- package/dist/api/routes/system.js.map +1 -0
- package/dist/api/routes/url-resolution.d.ts +3 -0
- package/dist/api/routes/url-resolution.d.ts.map +1 -0
- package/dist/api/routes/url-resolution.js +457 -0
- package/dist/api/routes/url-resolution.js.map +1 -0
- package/dist/api/routes/users.d.ts +3 -0
- package/dist/api/routes/users.d.ts.map +1 -0
- package/dist/api/routes/users.js +1162 -0
- package/dist/api/routes/users.js.map +1 -0
- package/dist/api/routes/webhooks.d.ts +3 -0
- package/dist/api/routes/webhooks.d.ts.map +1 -0
- package/dist/api/routes/webhooks.js +338 -0
- package/dist/api/routes/webhooks.js.map +1 -0
- package/dist/auth/invites.d.ts +9 -8
- package/dist/auth/invites.d.ts.map +1 -1
- package/dist/auth/invites.js.map +1 -1
- package/dist/auth/oauth.d.ts +2 -1
- package/dist/auth/oauth.d.ts.map +1 -1
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/reset.d.ts +3 -2
- package/dist/auth/reset.d.ts.map +1 -1
- package/dist/auth/reset.js +5 -2
- package/dist/auth/reset.js.map +1 -1
- package/dist/auth/session.d.ts +3 -2
- package/dist/auth/session.d.ts.map +1 -1
- package/dist/auth/session.js.map +1 -1
- package/dist/auth/team.d.ts +4 -3
- package/dist/auth/team.d.ts.map +1 -1
- package/dist/auth/team.js.map +1 -1
- package/dist/auth/user-admin.d.ts +6 -5
- package/dist/auth/user-admin.d.ts.map +1 -1
- package/dist/auth/user-admin.js.map +1 -1
- package/dist/content-health/cron.d.ts +2 -1
- package/dist/content-health/cron.d.ts.map +1 -1
- package/dist/content-health/cron.js.map +1 -1
- package/dist/cron/index.d.ts +2 -1
- package/dist/cron/index.d.ts.map +1 -1
- package/dist/cron/index.js +2 -0
- package/dist/cron/index.js.map +1 -1
- package/dist/db/client-types.d.ts +75 -0
- package/dist/db/client-types.d.ts.map +1 -0
- package/dist/db/client-types.js +2 -0
- package/dist/db/client-types.js.map +1 -0
- package/dist/db/model-types.d.ts +564 -0
- package/dist/db/model-types.d.ts.map +1 -0
- package/dist/db/model-types.js +14 -0
- package/dist/db/model-types.js.map +1 -0
- package/dist/diagnostics/api-metrics.d.ts.map +1 -1
- package/dist/diagnostics/api-metrics.js.map +1 -1
- package/dist/forms/entries.d.ts.map +1 -1
- package/dist/forms/entries.js.map +1 -1
- package/dist/forms/files.d.ts.map +1 -1
- package/dist/forms/files.js.map +1 -1
- package/dist/forms/service.d.ts.map +1 -1
- package/dist/forms/service.js.map +1 -1
- package/dist/forms/submission.d.ts.map +1 -1
- package/dist/forms/submission.js.map +1 -1
- package/dist/forms/webhooks.d.ts.map +1 -1
- package/dist/forms/webhooks.js.map +1 -1
- package/dist/graphql/index.d.ts.map +1 -1
- package/dist/graphql/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/scheduling/index.d.ts +2 -1
- package/dist/scheduling/index.d.ts.map +1 -1
- package/dist/scheduling/index.js.map +1 -1
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js.map +1 -1
- package/dist/security/audit.d.ts.map +1 -1
- package/dist/security/audit.js.map +1 -1
- package/dist/security/reauth.d.ts +2 -1
- package/dist/security/reauth.d.ts.map +1 -1
- package/dist/security/reauth.js.map +1 -1
- package/dist/seo/audit-runner.d.ts +3 -2
- package/dist/seo/audit-runner.d.ts.map +1 -1
- package/dist/seo/audit-runner.js.map +1 -1
- package/dist/server-site.d.ts +2 -1
- package/dist/server-site.d.ts.map +1 -1
- package/dist/server-site.js.map +1 -1
- package/dist/setup/index.d.ts +4 -3
- package/dist/setup/index.d.ts.map +1 -1
- package/dist/setup/index.js.map +1 -1
- package/dist/webhooks/index.d.ts +5 -5
- package/dist/webhooks/index.d.ts.map +1 -1
- package/dist/webhooks/index.js +8 -1
- package/dist/webhooks/index.js.map +1 -1
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
import { updateDocumentSeoFields } from '../../actions.js';
|
|
2
|
+
import { logEvent } from '../../security/audit.js';
|
|
3
|
+
import { processSeoScan } from '../../cron/index.js';
|
|
4
|
+
import { isResolvedIp } from '../../security/client-ip.js';
|
|
5
|
+
import { safeFetch, SsrfBlockedError } from '../../security/safe-fetch.js';
|
|
6
|
+
import { getActuateConfig } from '../../config/runtime.js';
|
|
7
|
+
import { guardedDb } from '../guarded-db.js';
|
|
8
|
+
import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, } from '../route-helpers.js';
|
|
9
|
+
// Explicit risk order. A plain `severity: 'asc'` DB sort orders the values
|
|
10
|
+
// alphabetically (critical, info, warning) which wrongly ranks `info` above
|
|
11
|
+
// `warning`. Unknown severities sort last.
|
|
12
|
+
const SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
|
|
13
|
+
function severityRank(severity) {
|
|
14
|
+
return SEVERITY_RANK[String(severity)] ?? 99;
|
|
15
|
+
}
|
|
16
|
+
/** Sort issues by risk (critical → warning → info), then most-recent first. */
|
|
17
|
+
export function sortIssuesBySeverity(issues) {
|
|
18
|
+
return [...issues].sort((a, b) => {
|
|
19
|
+
const rankDiff = severityRank(a.severity) - severityRank(b.severity);
|
|
20
|
+
if (rankDiff !== 0)
|
|
21
|
+
return rankDiff;
|
|
22
|
+
const at = a.lastDetectedAt ? new Date(a.lastDetectedAt).getTime() : 0;
|
|
23
|
+
const bt = b.lastDetectedAt ? new Date(b.lastDetectedAt).getTime() : 0;
|
|
24
|
+
return bt - at;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function registerSeoRoutes(router) {
|
|
28
|
+
const db = guardedDb;
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// SEO routes
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
router.get('/seo/pages', async (request) => {
|
|
33
|
+
try {
|
|
34
|
+
const auth = await requireAuth(request);
|
|
35
|
+
if (auth.error)
|
|
36
|
+
return auth.error;
|
|
37
|
+
const pages = await db().document.findMany({
|
|
38
|
+
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
39
|
+
select: {
|
|
40
|
+
id: true,
|
|
41
|
+
collection: true,
|
|
42
|
+
data: true,
|
|
43
|
+
updatedAt: true,
|
|
44
|
+
structuredData: true,
|
|
45
|
+
},
|
|
46
|
+
orderBy: { updatedAt: 'desc' },
|
|
47
|
+
});
|
|
48
|
+
return json({ data: pages.map(normalizeSeoPage) });
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return internalError(err);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
router.get('/seo/link-health', async (request) => {
|
|
55
|
+
try {
|
|
56
|
+
const auth = await requireAuth(request);
|
|
57
|
+
if (auth.error)
|
|
58
|
+
return auth.error;
|
|
59
|
+
// EDITOR+ only — this endpoint hits arbitrary URLs and can be expensive.
|
|
60
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
61
|
+
if (roleErr)
|
|
62
|
+
return roleErr;
|
|
63
|
+
// Tight rate limit because each call fans out to many outbound requests.
|
|
64
|
+
const ip = clientIp(request);
|
|
65
|
+
const rateKey = isResolvedIp(ip)
|
|
66
|
+
? `link-health:${ip}`
|
|
67
|
+
: `link-health-user:${auth.session.userId}`;
|
|
68
|
+
if (!(await checkRateLimitAsync(linkHealthLimiter, rateKey))) {
|
|
69
|
+
return errorResponse('Too many link-health scans. Please try again later.', 429);
|
|
70
|
+
}
|
|
71
|
+
const MAX_LINKS_PER_PAGE = 50;
|
|
72
|
+
const MAX_TOTAL_LINKS = 500;
|
|
73
|
+
const PER_LINK_TIMEOUT_MS = 4000;
|
|
74
|
+
const CONCURRENCY = 8;
|
|
75
|
+
const docs = await db().document.findMany({
|
|
76
|
+
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
77
|
+
select: { id: true, title: true, data: true, collection: true },
|
|
78
|
+
});
|
|
79
|
+
const urlRegex = /https?:\/\/[^\s"'<>]+/g;
|
|
80
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
|
|
81
|
+
const queue = [];
|
|
82
|
+
const seenGlobal = new Set();
|
|
83
|
+
outer: for (const doc of docs) {
|
|
84
|
+
const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
|
|
85
|
+
const content = JSON.stringify(doc.data ?? {});
|
|
86
|
+
const urls = content.match(urlRegex) ?? [];
|
|
87
|
+
const seenInPage = new Set();
|
|
88
|
+
let countInPage = 0;
|
|
89
|
+
for (const url of urls) {
|
|
90
|
+
const clean = url.replace(/[",;)}\]]+$/, '');
|
|
91
|
+
if (seenInPage.has(clean))
|
|
92
|
+
continue;
|
|
93
|
+
seenInPage.add(clean);
|
|
94
|
+
if (seenGlobal.has(clean))
|
|
95
|
+
continue;
|
|
96
|
+
seenGlobal.add(clean);
|
|
97
|
+
if (countInPage >= MAX_LINKS_PER_PAGE)
|
|
98
|
+
break;
|
|
99
|
+
if (queue.length >= MAX_TOTAL_LINKS)
|
|
100
|
+
break outer;
|
|
101
|
+
const isInternal = !!siteUrl && clean.startsWith(siteUrl);
|
|
102
|
+
queue.push({ docId: doc.id, pageTitle, clean, isInternal });
|
|
103
|
+
countInPage++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const linkResults = [];
|
|
107
|
+
let cursor = 0;
|
|
108
|
+
const worker = async () => {
|
|
109
|
+
while (cursor < queue.length) {
|
|
110
|
+
const idx = cursor++;
|
|
111
|
+
const job = queue[idx];
|
|
112
|
+
let status = 0;
|
|
113
|
+
try {
|
|
114
|
+
// safeFetch rejects private/loopback IPs and disables redirect
|
|
115
|
+
// following so 302->internal can't smuggle the scanner past SSRF.
|
|
116
|
+
const resp = await safeFetch(job.clean, {
|
|
117
|
+
method: 'HEAD',
|
|
118
|
+
timeoutMs: PER_LINK_TIMEOUT_MS,
|
|
119
|
+
});
|
|
120
|
+
status = resp.status;
|
|
121
|
+
// Drain the body so the connection can be reused.
|
|
122
|
+
try {
|
|
123
|
+
await resp.body?.cancel();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* noop */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
status = err instanceof SsrfBlockedError ? -1 : 0;
|
|
131
|
+
}
|
|
132
|
+
if (status === -1 || status === 0 || status >= 300) {
|
|
133
|
+
linkResults.push({
|
|
134
|
+
id: `${job.docId}-${idx}`,
|
|
135
|
+
page: job.pageTitle,
|
|
136
|
+
url: job.clean,
|
|
137
|
+
status: status === -1 ? 0 : status,
|
|
138
|
+
type: job.isInternal ? 'internal' : 'external',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
144
|
+
return json({
|
|
145
|
+
data: {
|
|
146
|
+
truncated: queue.length >= MAX_TOTAL_LINKS,
|
|
147
|
+
checked: queue.length,
|
|
148
|
+
issues: linkResults,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
return internalError(err);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
router.post('/seo/scan', async (request) => {
|
|
157
|
+
try {
|
|
158
|
+
const auth = await requireAuth(request);
|
|
159
|
+
if (auth.error)
|
|
160
|
+
return auth.error;
|
|
161
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
162
|
+
if (roleErr)
|
|
163
|
+
return roleErr;
|
|
164
|
+
// Expensive: loads + regex-scans every published doc. Rate-limit it (same
|
|
165
|
+
// budget as the full audit) so it can't be hammered into an OOM/CPU spike.
|
|
166
|
+
const scanIp = clientIp(request);
|
|
167
|
+
const scanKey = isResolvedIp(scanIp)
|
|
168
|
+
? `seo-scan:${scanIp}`
|
|
169
|
+
: `seo-scan-user:${auth.session.userId}`;
|
|
170
|
+
if (!(await checkRateLimitAsync(seoAuditLimiter, scanKey))) {
|
|
171
|
+
return errorResponse('Too many scans. Please try again later.', 429);
|
|
172
|
+
}
|
|
173
|
+
// Scope the scan to configured front-end content collections so structural
|
|
174
|
+
// (navigation/footer) and benchmark documents are never reported. Shares
|
|
175
|
+
// the exact scan logic with the cron path via processSeoScan.
|
|
176
|
+
const { frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
177
|
+
const allowedCollections = Object.keys(frontEndContentCollectionTypes(getActuateConfig()));
|
|
178
|
+
const result = await processSeoScan(db(), { allowedCollections, maxDocuments: 5000 });
|
|
179
|
+
return json({ data: result });
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
return internalError(err);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// SEO Operations Center — audit engine, issues, overview, content records
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
function seoIssueToApi(i) {
|
|
189
|
+
return {
|
|
190
|
+
id: i.id,
|
|
191
|
+
auditRunId: i.auditRunId ?? null,
|
|
192
|
+
entityType: i.entityType ?? null,
|
|
193
|
+
entityId: i.entityId ?? null,
|
|
194
|
+
entityTitle: i.entityTitle ?? null,
|
|
195
|
+
url: i.url ?? null,
|
|
196
|
+
severity: i.severity,
|
|
197
|
+
category: i.category,
|
|
198
|
+
type: i.type,
|
|
199
|
+
title: i.title,
|
|
200
|
+
description: i.description ?? null,
|
|
201
|
+
impact: i.impact ?? null,
|
|
202
|
+
recommendation: i.recommendation ?? null,
|
|
203
|
+
status: i.status,
|
|
204
|
+
fixable: i.fixable ?? false,
|
|
205
|
+
fixActionType: i.fixActionType ?? null,
|
|
206
|
+
relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
|
|
207
|
+
source: i.source ?? 'audit',
|
|
208
|
+
firstDetectedAt: i.firstDetectedAt ?? null,
|
|
209
|
+
lastDetectedAt: i.lastDetectedAt ?? null,
|
|
210
|
+
resolvedAt: i.resolvedAt ?? null,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
async function buildSeoContentRecords() {
|
|
214
|
+
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
215
|
+
const { calculatePageSeoScore } = await import('../../seo/score.js');
|
|
216
|
+
const collectionTypes = frontEndContentCollectionTypes(getActuateConfig());
|
|
217
|
+
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
218
|
+
return entities.map((e) => {
|
|
219
|
+
const score = calculatePageSeoScore(e);
|
|
220
|
+
return {
|
|
221
|
+
id: `${e.entityType}:${e.entityId}`,
|
|
222
|
+
entityType: e.entityType,
|
|
223
|
+
entityId: e.entityId,
|
|
224
|
+
title: e.title,
|
|
225
|
+
url: e.url,
|
|
226
|
+
canonicalUrl: e.canonicalUrl ?? null,
|
|
227
|
+
metaTitle: e.metaTitle ?? null,
|
|
228
|
+
metaDescription: e.metaDescription ?? null,
|
|
229
|
+
focusKeyword: e.focusKeyword ?? null,
|
|
230
|
+
ogTitle: e.ogTitle ?? null,
|
|
231
|
+
ogDescription: e.ogDescription ?? null,
|
|
232
|
+
ogImageId: null,
|
|
233
|
+
ogImage: e.ogImage ?? null,
|
|
234
|
+
twitterTitle: null,
|
|
235
|
+
twitterDescription: null,
|
|
236
|
+
twitterImageId: null,
|
|
237
|
+
noindex: e.noindex ?? false,
|
|
238
|
+
nofollow: false,
|
|
239
|
+
structuredDataType: e.structuredDataType ?? null,
|
|
240
|
+
structuredData: null,
|
|
241
|
+
seoScore: score.score,
|
|
242
|
+
seoGrade: score.grade,
|
|
243
|
+
updatedAt: null,
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// Demo/seed search-performance rows live in a hidden collection so they can
|
|
248
|
+
// be inserted by the dev seed script and never ship to npm. In production
|
|
249
|
+
// with a connected Search Console these would come from the GSC API.
|
|
250
|
+
const SEO_PERF_COLLECTION = '__seo_perf__';
|
|
251
|
+
async function loadSearchPerformance(limit = 10) {
|
|
252
|
+
try {
|
|
253
|
+
const docs = await db().document.findMany({
|
|
254
|
+
where: { collection: SEO_PERF_COLLECTION, deletedAt: null },
|
|
255
|
+
select: { data: true },
|
|
256
|
+
});
|
|
257
|
+
const rows = docs
|
|
258
|
+
.map((d) => {
|
|
259
|
+
const data = asRecord(d.data);
|
|
260
|
+
return {
|
|
261
|
+
url: String(data.url ?? ''),
|
|
262
|
+
title: String(data.title ?? ''),
|
|
263
|
+
impressions: Number(data.impressions ?? 0),
|
|
264
|
+
clicks: Number(data.clicks ?? 0),
|
|
265
|
+
ctr: Number(data.ctr ?? 0),
|
|
266
|
+
averagePosition: Number(data.averagePosition ?? 0),
|
|
267
|
+
};
|
|
268
|
+
})
|
|
269
|
+
.filter((r) => r.url);
|
|
270
|
+
rows.sort((a, b) => b.impressions - a.impressions);
|
|
271
|
+
return rows.slice(0, limit);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
router.post('/seo/audit', async (request) => {
|
|
278
|
+
try {
|
|
279
|
+
const auth = await requireAuth(request);
|
|
280
|
+
if (auth.error)
|
|
281
|
+
return auth.error;
|
|
282
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
283
|
+
if (roleErr)
|
|
284
|
+
return roleErr;
|
|
285
|
+
if (!hasModel(db(), 'seoAuditRun') || !hasModel(db(), 'seoIssue')) {
|
|
286
|
+
return modelNotAvailable('SeoAuditRun');
|
|
287
|
+
}
|
|
288
|
+
const ip = clientIp(request);
|
|
289
|
+
const rateKey = isResolvedIp(ip) ? `seo-audit:${ip}` : `seo-audit-user:${auth.session.userId}`;
|
|
290
|
+
if (!(await checkRateLimitAsync(seoAuditLimiter, rateKey))) {
|
|
291
|
+
return errorResponse('Too many audits. Please try again later.', 429);
|
|
292
|
+
}
|
|
293
|
+
const body = (await request.json().catch(() => ({})));
|
|
294
|
+
const scope = body?.scope ?? 'full';
|
|
295
|
+
const { runAndPersistAudit, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
296
|
+
const run = await runAndPersistAudit(db(), {
|
|
297
|
+
scope,
|
|
298
|
+
startedById: auth.session.userId,
|
|
299
|
+
collectionTypes: frontEndContentCollectionTypes(getActuateConfig()),
|
|
300
|
+
});
|
|
301
|
+
try {
|
|
302
|
+
await logEvent({
|
|
303
|
+
event: 'settings_changed',
|
|
304
|
+
userId: auth.session.userId,
|
|
305
|
+
details: { action: 'seo_audit', auditRunId: run.id, scope },
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
/* audit-log failures never block */
|
|
310
|
+
}
|
|
311
|
+
return json({ data: run });
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
return internalError(err, 'seo/audit');
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
router.get('/seo/audits', async (request) => {
|
|
318
|
+
try {
|
|
319
|
+
const auth = await requireAuth(request);
|
|
320
|
+
if (auth.error)
|
|
321
|
+
return auth.error;
|
|
322
|
+
if (!hasModel(db(), 'seoAuditRun'))
|
|
323
|
+
return json({ data: { runs: [] } });
|
|
324
|
+
const runs = await db().seoAuditRun.findMany({
|
|
325
|
+
orderBy: { startedAt: 'desc' },
|
|
326
|
+
take: 50,
|
|
327
|
+
});
|
|
328
|
+
return json({ data: { runs } });
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
return internalError(err, 'seo/audits');
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
router.get('/seo/audits/:id', async (request, params) => {
|
|
335
|
+
try {
|
|
336
|
+
const auth = await requireAuth(request);
|
|
337
|
+
if (auth.error)
|
|
338
|
+
return auth.error;
|
|
339
|
+
if (!hasModel(db(), 'seoAuditRun'))
|
|
340
|
+
return modelNotAvailable('SeoAuditRun');
|
|
341
|
+
const run = await db().seoAuditRun.findUnique({ where: { id: params.id } });
|
|
342
|
+
if (!run)
|
|
343
|
+
return errorResponse('Audit run not found', 404);
|
|
344
|
+
return json({ data: run });
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
return internalError(err, 'seo/audits/:id');
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
router.get('/seo/issues', async (request) => {
|
|
351
|
+
try {
|
|
352
|
+
const auth = await requireAuth(request);
|
|
353
|
+
if (auth.error)
|
|
354
|
+
return auth.error;
|
|
355
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
356
|
+
return json({ data: { issues: [] } });
|
|
357
|
+
const url = new URL(request.url);
|
|
358
|
+
const where = {};
|
|
359
|
+
const severity = url.searchParams.get('severity');
|
|
360
|
+
const category = url.searchParams.get('category');
|
|
361
|
+
const status = url.searchParams.get('status');
|
|
362
|
+
const auditRunId = url.searchParams.get('auditRunId');
|
|
363
|
+
if (severity)
|
|
364
|
+
where.severity = severity;
|
|
365
|
+
if (category)
|
|
366
|
+
where.category = category;
|
|
367
|
+
if (status)
|
|
368
|
+
where.status = status;
|
|
369
|
+
else
|
|
370
|
+
where.status = { in: ['open', 'recurring'] };
|
|
371
|
+
if (auditRunId)
|
|
372
|
+
where.auditRunId = auditRunId;
|
|
373
|
+
const issues = await db().seoIssue.findMany({
|
|
374
|
+
where,
|
|
375
|
+
orderBy: { lastDetectedAt: 'desc' },
|
|
376
|
+
take: 500,
|
|
377
|
+
});
|
|
378
|
+
return json({ data: { issues: sortIssuesBySeverity(issues).map(seoIssueToApi) } });
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
return internalError(err, 'seo/issues');
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
router.get('/seo/issues/:id', async (request, params) => {
|
|
385
|
+
try {
|
|
386
|
+
const auth = await requireAuth(request);
|
|
387
|
+
if (auth.error)
|
|
388
|
+
return auth.error;
|
|
389
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
390
|
+
return modelNotAvailable('SeoIssue');
|
|
391
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
392
|
+
if (!issue)
|
|
393
|
+
return errorResponse('Issue not found', 404);
|
|
394
|
+
return json({ data: seoIssueToApi(issue) });
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
return internalError(err, 'seo/issues/:id');
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
async function setIssueStatus(request, issueId, status) {
|
|
401
|
+
const auth = await requireAuth(request);
|
|
402
|
+
if (auth.error)
|
|
403
|
+
return auth.error;
|
|
404
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
405
|
+
if (roleErr)
|
|
406
|
+
return roleErr;
|
|
407
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
408
|
+
return modelNotAvailable('SeoIssue');
|
|
409
|
+
const existing = await db().seoIssue.findUnique({ where: { id: issueId } });
|
|
410
|
+
if (!existing)
|
|
411
|
+
return errorResponse('Issue not found', 404);
|
|
412
|
+
const issue = await db().seoIssue.update({
|
|
413
|
+
where: { id: issueId },
|
|
414
|
+
data: {
|
|
415
|
+
status,
|
|
416
|
+
resolvedAt: status === 'resolved' ? new Date() : null,
|
|
417
|
+
resolvedById: status === 'resolved' ? auth.session.userId : null,
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
try {
|
|
421
|
+
await logEvent({
|
|
422
|
+
event: 'settings_changed',
|
|
423
|
+
userId: auth.session.userId,
|
|
424
|
+
details: { action: 'seo_issue_status', issueId, status },
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
/* noop */
|
|
429
|
+
}
|
|
430
|
+
return json({ data: seoIssueToApi(issue) });
|
|
431
|
+
}
|
|
432
|
+
router.post('/seo/issues/:id/resolve', (request, params) => setIssueStatus(request, params.id, 'resolved').catch((e) => internalError(e)));
|
|
433
|
+
router.post('/seo/issues/:id/ignore', (request, params) => setIssueStatus(request, params.id, 'ignored').catch((e) => internalError(e)));
|
|
434
|
+
router.post('/seo/issues/:id/reopen', (request, params) => setIssueStatus(request, params.id, 'open').catch((e) => internalError(e)));
|
|
435
|
+
router.get('/seo/overview', async (request) => {
|
|
436
|
+
try {
|
|
437
|
+
const auth = await requireAuth(request);
|
|
438
|
+
if (auth.error)
|
|
439
|
+
return auth.error;
|
|
440
|
+
const { resolveSeoModuleSettings, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
441
|
+
const { buildQuickWins } = await import('../../seo/audit-engine.js');
|
|
442
|
+
const { gradeForSeoScore } = await import('../../seo/score.js');
|
|
443
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
444
|
+
const moduleSettings = resolveSeoModuleSettings(overrides);
|
|
445
|
+
let lastRun = null;
|
|
446
|
+
let openIssues = [];
|
|
447
|
+
if (hasModel(db(), 'seoAuditRun')) {
|
|
448
|
+
lastRun = await db().seoAuditRun.findFirst({
|
|
449
|
+
where: { status: 'COMPLETED' },
|
|
450
|
+
orderBy: { startedAt: 'desc' },
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
if (hasModel(db(), 'seoIssue')) {
|
|
454
|
+
const rawIssues = await db().seoIssue.findMany({
|
|
455
|
+
where: { status: { in: ['open', 'recurring'] } },
|
|
456
|
+
orderBy: { lastDetectedAt: 'desc' },
|
|
457
|
+
take: 200,
|
|
458
|
+
});
|
|
459
|
+
openIssues = sortIssuesBySeverity(rawIssues);
|
|
460
|
+
}
|
|
461
|
+
const records = await buildSeoContentRecords().catch(() => []);
|
|
462
|
+
const indexable = records.filter((r) => !r.noindex).length;
|
|
463
|
+
const perf = await loadSearchPerformance(10);
|
|
464
|
+
const summary = {
|
|
465
|
+
total: openIssues.length,
|
|
466
|
+
critical: openIssues.filter((i) => i.severity === 'critical').length,
|
|
467
|
+
warning: openIssues.filter((i) => i.severity === 'warning').length,
|
|
468
|
+
info: openIssues.filter((i) => i.severity === 'info').length,
|
|
469
|
+
};
|
|
470
|
+
const scoreAfter = lastRun?.siteScoreAfter ?? null;
|
|
471
|
+
const scoreBefore = lastRun?.siteScoreBefore ?? null;
|
|
472
|
+
const breakdown = lastRun?.metadata?.breakdown ?? [];
|
|
473
|
+
const avgPosition = perf.length > 0
|
|
474
|
+
? perf.reduce((s, r) => s + r.averagePosition, 0) / perf.length
|
|
475
|
+
: null;
|
|
476
|
+
const weeklyImpressions = perf.reduce((s, r) => s + r.impressions, 0);
|
|
477
|
+
const metrics = [
|
|
478
|
+
{
|
|
479
|
+
label: 'Pages Indexed',
|
|
480
|
+
value: String(indexable),
|
|
481
|
+
helper: `${records.length} total pages`,
|
|
482
|
+
trend: 'flat',
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
label: 'Open Issues',
|
|
486
|
+
value: String(summary.total),
|
|
487
|
+
helper: `${summary.critical} critical`,
|
|
488
|
+
trend: summary.critical > 0 ? 'down' : 'flat',
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
label: 'Avg. Position',
|
|
492
|
+
value: avgPosition !== null ? avgPosition.toFixed(1) : '—',
|
|
493
|
+
helper: moduleSettings.searchConsoleConnected ? 'last 28 days' : 'Connect Search Console',
|
|
494
|
+
trend: 'flat',
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
label: 'Weekly Impressions',
|
|
498
|
+
value: weeklyImpressions > 0 ? formatCompactNumber(weeklyImpressions) : '—',
|
|
499
|
+
helper: moduleSettings.searchConsoleConnected ? 'last 28 days' : 'Connect Search Console',
|
|
500
|
+
trend: 'flat',
|
|
501
|
+
},
|
|
502
|
+
];
|
|
503
|
+
const quickWins = buildQuickWins(openIssues.map((i) => ({
|
|
504
|
+
issueKey: i.issueKey,
|
|
505
|
+
entityType: i.entityType,
|
|
506
|
+
entityId: i.entityId,
|
|
507
|
+
entityTitle: i.entityTitle,
|
|
508
|
+
url: i.url,
|
|
509
|
+
severity: i.severity,
|
|
510
|
+
category: i.category,
|
|
511
|
+
type: i.type,
|
|
512
|
+
title: i.title,
|
|
513
|
+
description: i.description ?? '',
|
|
514
|
+
impact: i.impact ?? 'Low',
|
|
515
|
+
recommendation: i.recommendation ?? '',
|
|
516
|
+
fixable: i.fixable ?? false,
|
|
517
|
+
fixActionType: i.fixActionType ?? null,
|
|
518
|
+
relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
|
|
519
|
+
}))).map((w) => ({
|
|
520
|
+
id: w.id,
|
|
521
|
+
title: w.title,
|
|
522
|
+
explanation: w.explanation,
|
|
523
|
+
impact: w.impact,
|
|
524
|
+
issueId: openIssues.find((i) => i.type === w.issueType)?.id ?? null,
|
|
525
|
+
fixActionType: w.fixActionType,
|
|
526
|
+
}));
|
|
527
|
+
return json({
|
|
528
|
+
data: {
|
|
529
|
+
siteScore: {
|
|
530
|
+
score: scoreAfter ?? 0,
|
|
531
|
+
grade: gradeForSeoScore(scoreAfter ?? 0),
|
|
532
|
+
delta: scoreAfter !== null && scoreBefore !== null ? scoreAfter - scoreBefore : null,
|
|
533
|
+
breakdown,
|
|
534
|
+
},
|
|
535
|
+
metrics,
|
|
536
|
+
issues: openIssues.slice(0, 7).map(seoIssueToApi),
|
|
537
|
+
issuesSummary: summary,
|
|
538
|
+
quickWins,
|
|
539
|
+
searchPerformance: perf,
|
|
540
|
+
searchConsoleConnected: moduleSettings.searchConsoleConnected,
|
|
541
|
+
lastAuditRunId: lastRun?.id ?? null,
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
return internalError(err, 'seo/overview');
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
router.get('/seo/search-performance', async (request) => {
|
|
550
|
+
try {
|
|
551
|
+
const auth = await requireAuth(request);
|
|
552
|
+
if (auth.error)
|
|
553
|
+
return auth.error;
|
|
554
|
+
const url = new URL(request.url);
|
|
555
|
+
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
|
|
556
|
+
const rows = await loadSearchPerformance(limit);
|
|
557
|
+
return json({ data: { rows } });
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
return internalError(err, 'seo/search-performance');
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
router.get('/seo/content', async (request) => {
|
|
564
|
+
try {
|
|
565
|
+
const auth = await requireAuth(request);
|
|
566
|
+
if (auth.error)
|
|
567
|
+
return auth.error;
|
|
568
|
+
const records = await buildSeoContentRecords();
|
|
569
|
+
return json({ data: { records } });
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
return internalError(err, 'seo/content');
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
router.get('/seo/content/:entityType/:entityId', async (request, params) => {
|
|
576
|
+
try {
|
|
577
|
+
const auth = await requireAuth(request);
|
|
578
|
+
if (auth.error)
|
|
579
|
+
return auth.error;
|
|
580
|
+
const records = await buildSeoContentRecords();
|
|
581
|
+
const record = records.find((r) => r.entityType === params.entityType && r.entityId === params.entityId);
|
|
582
|
+
if (!record)
|
|
583
|
+
return errorResponse('Content record not found', 404);
|
|
584
|
+
return json({ data: record });
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
return internalError(err, 'seo/content/:id');
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
router.put('/seo/content/:entityType/:entityId', async (request, params) => {
|
|
591
|
+
try {
|
|
592
|
+
const auth = await requireAuth(request);
|
|
593
|
+
if (auth.error)
|
|
594
|
+
return auth.error;
|
|
595
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
596
|
+
if (roleErr)
|
|
597
|
+
return roleErr;
|
|
598
|
+
const body = (await request.json().catch(() => null));
|
|
599
|
+
if (!body || typeof body !== 'object') {
|
|
600
|
+
return errorResponse('Request body must be an object', 400);
|
|
601
|
+
}
|
|
602
|
+
const doc = await db().document.findFirst({
|
|
603
|
+
where: { id: params.entityId, deletedAt: null },
|
|
604
|
+
select: { id: true, collection: true },
|
|
605
|
+
});
|
|
606
|
+
if (!doc)
|
|
607
|
+
return errorResponse('Document not found', 404);
|
|
608
|
+
// String SEO fields: payload key -> stored data key, each with a max
|
|
609
|
+
// length so we never persist unbounded/megabyte values into the content
|
|
610
|
+
// JSON. Canonical is stored as `canonical` to match normalizeSeoPage.
|
|
611
|
+
const TEXT_FIELDS = {
|
|
612
|
+
metaTitle: { target: 'metaTitle', max: 300 },
|
|
613
|
+
metaDescription: { target: 'metaDescription', max: 1000 },
|
|
614
|
+
canonicalUrl: { target: 'canonical', max: 2000 },
|
|
615
|
+
focusKeyword: { target: 'focusKeyword', max: 200 },
|
|
616
|
+
ogTitle: { target: 'ogTitle', max: 300 },
|
|
617
|
+
ogDescription: { target: 'ogDescription', max: 1000 },
|
|
618
|
+
ogImage: { target: 'ogImage', max: 2000 },
|
|
619
|
+
twitterTitle: { target: 'twitterTitle', max: 300 },
|
|
620
|
+
twitterDescription: { target: 'twitterDescription', max: 1000 },
|
|
621
|
+
twitterImage: { target: 'twitterImage', max: 2000 },
|
|
622
|
+
structuredDataType: { target: 'structuredDataType', max: 100 },
|
|
623
|
+
};
|
|
624
|
+
const patch = {};
|
|
625
|
+
for (const [key, { target, max }] of Object.entries(TEXT_FIELDS)) {
|
|
626
|
+
if (!(key in body))
|
|
627
|
+
continue;
|
|
628
|
+
const value = body[key];
|
|
629
|
+
if (value === null || value === '') {
|
|
630
|
+
patch[target] = '';
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (typeof value !== 'string') {
|
|
634
|
+
return errorResponse(`Field "${key}" must be a string`, 400);
|
|
635
|
+
}
|
|
636
|
+
if (value.length > max) {
|
|
637
|
+
return errorResponse(`Field "${key}" exceeds the ${max}-character limit`, 400);
|
|
638
|
+
}
|
|
639
|
+
patch[target] = value;
|
|
640
|
+
}
|
|
641
|
+
if ('noindex' in body)
|
|
642
|
+
patch.noindex = body.noindex === true;
|
|
643
|
+
if ('nofollow' in body)
|
|
644
|
+
patch.nofollow = body.nofollow === true;
|
|
645
|
+
if ('structuredData' in body) {
|
|
646
|
+
const sd = body.structuredData;
|
|
647
|
+
if (sd !== null && (typeof sd !== 'object' || Array.isArray(sd))) {
|
|
648
|
+
return errorResponse('structuredData must be a JSON object', 400);
|
|
649
|
+
}
|
|
650
|
+
if (sd && JSON.stringify(sd).length > 50_000) {
|
|
651
|
+
return errorResponse('structuredData payload is too large', 400);
|
|
652
|
+
}
|
|
653
|
+
patch.structuredData = sd;
|
|
654
|
+
}
|
|
655
|
+
if (Object.keys(patch).length === 0) {
|
|
656
|
+
return errorResponse('No recognized SEO fields in request body', 400);
|
|
657
|
+
}
|
|
658
|
+
// Route through the data layer so the edit gets per-collection access
|
|
659
|
+
// control, a Version snapshot, search re-indexing and the afterUpdate
|
|
660
|
+
// hook — never write document.data directly. (updateDocumentSeoFields
|
|
661
|
+
// preserves the undeclared SEO convention keys that the generic
|
|
662
|
+
// updateDocument field-access layer would strip.)
|
|
663
|
+
const ctx = buildActionContext(auth.session, db());
|
|
664
|
+
try {
|
|
665
|
+
await updateDocumentSeoFields(doc.collection, doc.id, patch, ctx);
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
const msg = err instanceof Error ? err.message : '';
|
|
669
|
+
if (msg.includes('Access denied'))
|
|
670
|
+
return errorResponse('Insufficient permissions', 403);
|
|
671
|
+
if (msg.includes('not found'))
|
|
672
|
+
return errorResponse('Document not found', 404);
|
|
673
|
+
throw err;
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
await logEvent({
|
|
677
|
+
event: 'document_updated',
|
|
678
|
+
userId: auth.session.userId,
|
|
679
|
+
details: { action: 'seo_fields_update', documentId: doc.id },
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
/* noop */
|
|
684
|
+
}
|
|
685
|
+
const records = await buildSeoContentRecords();
|
|
686
|
+
const record = records.find((r) => r.entityId === doc.id);
|
|
687
|
+
return json({ data: record ?? { ok: true } });
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
return internalError(err, 'seo/content PUT');
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// SEO analysis, readability, internal links, schema, meta
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
router.get('/seo/analysis/:documentId', async (request, params) => {
|
|
697
|
+
try {
|
|
698
|
+
const auth = await requireAuth(request);
|
|
699
|
+
if (auth.error)
|
|
700
|
+
return auth.error;
|
|
701
|
+
const doc = await db().document.findFirst({
|
|
702
|
+
where: { id: params.documentId, deletedAt: null },
|
|
703
|
+
});
|
|
704
|
+
if (!doc)
|
|
705
|
+
return errorResponse('Not found', 404);
|
|
706
|
+
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
707
|
+
if (accessErr)
|
|
708
|
+
return accessErr;
|
|
709
|
+
const { analyzeContent } = await import('../../seo/analysis.js');
|
|
710
|
+
const data = doc.data || {};
|
|
711
|
+
const result = analyzeContent({
|
|
712
|
+
title: doc.title || data.title || '',
|
|
713
|
+
slug: doc.slug || data.slug || '',
|
|
714
|
+
content: data.content || data.body || '',
|
|
715
|
+
metaTitle: data.metaTitle || data.seoTitle,
|
|
716
|
+
metaDescription: data.metaDescription || data.seoDescription,
|
|
717
|
+
focusKeyphrase: data.focusKeyphrase,
|
|
718
|
+
canonical: data.canonical,
|
|
719
|
+
ogTitle: data.ogTitle,
|
|
720
|
+
ogDescription: data.ogDescription,
|
|
721
|
+
ogImage: data.ogImage,
|
|
722
|
+
isCornerstone: data.isCornerstone,
|
|
723
|
+
});
|
|
724
|
+
return new Response(JSON.stringify(result), {
|
|
725
|
+
status: 200,
|
|
726
|
+
headers: { 'Content-Type': 'application/json' },
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
return internalError(err, 'seo/analysis');
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
router.get('/seo/readability/:documentId', async (request, params) => {
|
|
734
|
+
try {
|
|
735
|
+
const auth = await requireAuth(request);
|
|
736
|
+
if (auth.error)
|
|
737
|
+
return auth.error;
|
|
738
|
+
const doc = await db().document.findFirst({
|
|
739
|
+
where: { id: params.documentId, deletedAt: null },
|
|
740
|
+
});
|
|
741
|
+
if (!doc)
|
|
742
|
+
return errorResponse('Not found', 404);
|
|
743
|
+
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
744
|
+
if (accessErr)
|
|
745
|
+
return accessErr;
|
|
746
|
+
const { calculateReadability, stripHtmlTags } = await import('../../seo/analysis.js');
|
|
747
|
+
const data = doc.data || {};
|
|
748
|
+
const text = stripHtmlTags(data.content || data.body || '');
|
|
749
|
+
const result = calculateReadability(text);
|
|
750
|
+
return new Response(JSON.stringify(result), {
|
|
751
|
+
status: 200,
|
|
752
|
+
headers: { 'Content-Type': 'application/json' },
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
catch (err) {
|
|
756
|
+
return internalError(err, 'seo/readability');
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
router.get('/seo/internal-links/:documentId', async (request, params) => {
|
|
760
|
+
try {
|
|
761
|
+
const auth = await requireAuth(request);
|
|
762
|
+
if (auth.error)
|
|
763
|
+
return auth.error;
|
|
764
|
+
const doc = await db().document.findFirst({
|
|
765
|
+
where: { id: params.documentId, deletedAt: null },
|
|
766
|
+
});
|
|
767
|
+
if (!doc)
|
|
768
|
+
return errorResponse('Not found', 404);
|
|
769
|
+
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
770
|
+
if (accessErr)
|
|
771
|
+
return accessErr;
|
|
772
|
+
const data = doc.data || {};
|
|
773
|
+
const title = doc.title || data.title || '';
|
|
774
|
+
const keywords = title
|
|
775
|
+
.split(/\s+/)
|
|
776
|
+
.filter((w) => w.length > 3)
|
|
777
|
+
.slice(0, 5);
|
|
778
|
+
if (keywords.length === 0) {
|
|
779
|
+
return new Response(JSON.stringify({ suggestions: [] }), {
|
|
780
|
+
status: 200,
|
|
781
|
+
headers: { 'Content-Type': 'application/json' },
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
const suggestions = await db().document.findMany({
|
|
785
|
+
where: {
|
|
786
|
+
id: { not: params.documentId },
|
|
787
|
+
status: 'PUBLISHED',
|
|
788
|
+
deletedAt: null,
|
|
789
|
+
OR: keywords.map((kw) => ({
|
|
790
|
+
title: { contains: kw, mode: 'insensitive' },
|
|
791
|
+
})),
|
|
792
|
+
},
|
|
793
|
+
select: { id: true, title: true, slug: true, collection: true },
|
|
794
|
+
take: 10,
|
|
795
|
+
orderBy: { updatedAt: 'desc' },
|
|
796
|
+
});
|
|
797
|
+
return new Response(JSON.stringify({ suggestions }), {
|
|
798
|
+
status: 200,
|
|
799
|
+
headers: { 'Content-Type': 'application/json' },
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
return internalError(err, 'seo/internal-links');
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
router.get('/llms.txt', async () => {
|
|
807
|
+
try {
|
|
808
|
+
// Public surface: must exclude soft-deleted docs and internal/system
|
|
809
|
+
// collections (e.g. `__seo_config__`). Over-fetch then filter so the
|
|
810
|
+
// 50-page cap still lands on real content after exclusion.
|
|
811
|
+
const { isInternalCollection } = await import('../../seo/audit-runner.js');
|
|
812
|
+
const rawDocs = await db().document.findMany({
|
|
813
|
+
where: { status: 'PUBLISHED', deletedAt: null },
|
|
814
|
+
select: { title: true, slug: true, collection: true, updatedAt: true, data: true },
|
|
815
|
+
orderBy: { updatedAt: 'desc' },
|
|
816
|
+
take: 200,
|
|
817
|
+
});
|
|
818
|
+
const docs = rawDocs.filter((d) => !isInternalCollection(d.collection)).slice(0, 50);
|
|
819
|
+
const { generateLlmsTxt } = await import('../../seo/llms-txt.js');
|
|
820
|
+
const pages = docs.map((d) => {
|
|
821
|
+
const data = d.data || {};
|
|
822
|
+
return {
|
|
823
|
+
title: d.title || data.title || 'Untitled',
|
|
824
|
+
url: `/${d.collection}/${d.slug || d.title?.toLowerCase().replace(/\s+/g, '-')}`,
|
|
825
|
+
description: data.metaDescription || data.seoDescription || '',
|
|
826
|
+
collection: d.collection,
|
|
827
|
+
updatedAt: d.updatedAt?.toISOString(),
|
|
828
|
+
isCornerstone: data.isCornerstone === true,
|
|
829
|
+
};
|
|
830
|
+
});
|
|
831
|
+
const siteUrl = '';
|
|
832
|
+
const txt = generateLlmsTxt({
|
|
833
|
+
siteName: 'Actuate CMS',
|
|
834
|
+
siteUrl,
|
|
835
|
+
siteDescription: 'Content managed by Actuate CMS',
|
|
836
|
+
maxPages: 50,
|
|
837
|
+
}, pages);
|
|
838
|
+
return new Response(txt, {
|
|
839
|
+
status: 200,
|
|
840
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
catch (err) {
|
|
844
|
+
return internalError(err, 'llms.txt');
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
//# sourceMappingURL=seo.js.map
|