@prenta/core 7.6.6 → 7.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/api/routes/seo/handlers/analysis.d.ts +4 -0
  3. package/dist/api/routes/seo/handlers/analysis.d.ts.map +1 -0
  4. package/dist/api/routes/seo/handlers/analysis.js +131 -0
  5. package/dist/api/routes/seo/handlers/analysis.js.map +1 -0
  6. package/dist/api/routes/seo/handlers/audits.d.ts +5 -0
  7. package/dist/api/routes/seo/handlers/audits.d.ts.map +1 -0
  8. package/dist/api/routes/seo/handlers/audits.js +121 -0
  9. package/dist/api/routes/seo/handlers/audits.js.map +1 -0
  10. package/dist/api/routes/seo/handlers/content.d.ts +4 -0
  11. package/dist/api/routes/seo/handlers/content.d.ts.map +1 -0
  12. package/dist/api/routes/seo/handlers/content.js +330 -0
  13. package/dist/api/routes/seo/handlers/content.js.map +1 -0
  14. package/dist/api/routes/seo/handlers/issue-helpers.d.ts +37 -0
  15. package/dist/api/routes/seo/handlers/issue-helpers.d.ts.map +1 -0
  16. package/dist/api/routes/seo/handlers/issue-helpers.js +72 -0
  17. package/dist/api/routes/seo/handlers/issue-helpers.js.map +1 -0
  18. package/dist/api/routes/seo/handlers/issues-briefs.d.ts +7 -0
  19. package/dist/api/routes/seo/handlers/issues-briefs.d.ts.map +1 -0
  20. package/dist/api/routes/seo/handlers/issues-briefs.js +341 -0
  21. package/dist/api/routes/seo/handlers/issues-briefs.js.map +1 -0
  22. package/dist/api/routes/seo/handlers/issues-fixes.d.ts +4 -0
  23. package/dist/api/routes/seo/handlers/issues-fixes.d.ts.map +1 -0
  24. package/dist/api/routes/seo/handlers/issues-fixes.js +233 -0
  25. package/dist/api/routes/seo/handlers/issues-fixes.js.map +1 -0
  26. package/dist/api/routes/seo/handlers/issues-list.d.ts +7 -0
  27. package/dist/api/routes/seo/handlers/issues-list.d.ts.map +1 -0
  28. package/dist/api/routes/seo/handlers/issues-list.js +191 -0
  29. package/dist/api/routes/seo/handlers/issues-list.js.map +1 -0
  30. package/dist/api/routes/seo/handlers/links.d.ts +6 -0
  31. package/dist/api/routes/seo/handlers/links.d.ts.map +1 -0
  32. package/dist/api/routes/seo/handlers/links.js +383 -0
  33. package/dist/api/routes/seo/handlers/links.js.map +1 -0
  34. package/dist/api/routes/seo/handlers/overview.d.ts +4 -0
  35. package/dist/api/routes/seo/handlers/overview.d.ts.map +1 -0
  36. package/dist/api/routes/seo/handlers/overview.js +348 -0
  37. package/dist/api/routes/seo/handlers/overview.js.map +1 -0
  38. package/dist/api/routes/seo/handlers/pages.d.ts +2 -0
  39. package/dist/api/routes/seo/handlers/pages.d.ts.map +1 -0
  40. package/dist/api/routes/seo/handlers/pages.js +38 -0
  41. package/dist/api/routes/seo/handlers/pages.js.map +1 -0
  42. package/dist/api/routes/seo/handlers/proposals.d.ts +4 -0
  43. package/dist/api/routes/seo/handlers/proposals.d.ts.map +1 -0
  44. package/dist/api/routes/seo/handlers/proposals.js +93 -0
  45. package/dist/api/routes/seo/handlers/proposals.js.map +1 -0
  46. package/dist/api/routes/seo/handlers/public-files.d.ts +6 -0
  47. package/dist/api/routes/seo/handlers/public-files.d.ts.map +1 -0
  48. package/dist/api/routes/seo/handlers/public-files.js +328 -0
  49. package/dist/api/routes/seo/handlers/public-files.js.map +1 -0
  50. package/dist/api/routes/seo/handlers/shared.d.ts +24 -0
  51. package/dist/api/routes/seo/handlers/shared.d.ts.map +1 -0
  52. package/dist/api/routes/seo/handlers/shared.js +67 -0
  53. package/dist/api/routes/seo/handlers/shared.js.map +1 -0
  54. package/dist/api/routes/seo.d.ts +5 -5
  55. package/dist/api/routes/seo.d.ts.map +1 -1
  56. package/dist/api/routes/seo.js +56 -2610
  57. package/dist/api/routes/seo.js.map +1 -1
  58. package/dist/cache/http.d.ts +5 -0
  59. package/dist/cache/http.d.ts.map +1 -1
  60. package/dist/cache/http.js +6 -0
  61. package/dist/cache/http.js.map +1 -1
  62. package/dist/index.d.ts +1 -1
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +1 -1
  65. package/dist/index.js.map +1 -1
  66. package/dist/next-site.d.ts.map +1 -1
  67. package/dist/next-site.js +6 -1
  68. package/dist/next-site.js.map +1 -1
  69. package/package.json +1 -1
@@ -1,2615 +1,61 @@
1
- import { updateDocumentSeoFields } from '../../actions.js';
2
- import { logEvent } from '../../security/audit.js';
3
- import { DEFAULT_DIRECTIVES, coerceMaxImagePreview } from '../../seo/robots-directives.js';
4
- import { processSeoScan } from '../../cron/index.js';
5
- import { isResolvedIp } from '../../security/client-ip.js';
6
- import { classifyLinkHealthIssue, extractOutboundUrlsFromDocumentData, probeOutboundLink, } from '../../seo/link-probe.js';
7
- import { documentPublicPath, resolveTrailingSlashPolicy, trailingSlashMismatchTarget, } from '../../seo/document-path.js';
8
- import { getPrentaConfig } from '../../config/runtime.js';
9
- import { createLogger } from '../../diagnostics/logger.js';
10
- import { assertPlanFeature, hasPlanFeature } from '../../plan/features.js';
11
- import { extraImagesForSeoAnalysis, firstSectionHeading } from '../../seo/document-content.js';
12
- import { syncSchemaTypeFields } from '../../seo/schema-sync.js';
13
- import { guardedDb } from '../guarded-db.js';
14
- import { recordAiCrawlerFromRequest, recordAiCrawlerHit } from '../../seo/ai-crawler-log.js';
15
- import { detectAiBot } from '../../seo/ai-bots.js';
16
- import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, ADMIN_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, aiGenerateLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, apiKeyCollectionScopeDenial, clampPage, clampPageSize, } from '../route-helpers.js';
17
- import { gatherNavigationLinkTargets } from '../../seo/nav-link-targets.js';
18
- import { suggestInboundLinks } from '../../seo/link-suggest.js';
19
- import { seoApplyFixBodySchema, seoBulkApplyBodySchema, seoLinkApplyBodySchema, validateBody, } from '../validate-body.js';
20
- // Explicit risk order. A plain `severity: 'asc'` DB sort orders the values
21
- // alphabetically (critical, info, warning) which wrongly ranks `info` above
22
- // `warning`. Unknown severities sort last.
23
- const seoLogger = createLogger('seo');
24
- const SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
25
- function severityRank(severity) {
26
- return SEVERITY_RANK[String(severity)] ?? 99;
27
- }
28
- function planRequiredResponse(denial) {
29
- return json({
30
- error: denial.message,
31
- code: 'plan_required',
32
- requiredTier: denial.requiredTier,
33
- feature: denial.feature,
34
- }, 403);
35
- }
36
- function mapInternalLinkInsertError(err) {
37
- const msg = err instanceof Error ? err.message : '';
38
- switch (msg) {
39
- case 'SOURCE_NOT_FOUND':
40
- return errorResponse('Source document not found', 404);
41
- case 'TARGET_NOT_FOUND':
42
- return errorResponse('Target document not found', 404);
43
- case 'NOT_WRAPPABLE':
44
- return errorResponse('Anchor is not wrappable in the live source document.', 422);
45
- case 'ALREADY_LINKED':
46
- return errorResponse('Source already links to this target.', 422);
47
- case 'TARGET_PATH_MISMATCH':
48
- return errorResponse('Target path does not match the live document.', 422);
49
- default:
50
- return null;
51
- }
52
- }
53
- function seoAiGenerationError(result) {
54
- if (result.code === 'unavailable' || result.code === 'feature_disabled') {
55
- return errorResponse(result.message, 503);
56
- }
57
- if (result.code === 'rate_limited' || result.code === 'budget_exceeded') {
58
- return errorResponse(result.message, 429);
59
- }
60
- return errorResponse(result.message, 422);
61
- }
62
- /** Sort issues by risk (critical → warning → info), then most-recent first. */
63
- export function sortIssuesBySeverity(issues) {
64
- return [...issues].sort((a, b) => {
65
- const rankDiff = severityRank(a.severity) - severityRank(b.severity);
66
- if (rankDiff !== 0)
67
- return rankDiff;
68
- const at = a.lastDetectedAt ? new Date(a.lastDetectedAt).getTime() : 0;
69
- const bt = b.lastDetectedAt ? new Date(b.lastDetectedAt).getTime() : 0;
70
- return bt - at;
71
- });
72
- }
1
+ import { getSeoPages } from './seo/handlers/pages.js';
2
+ import { getSeoLinkHealth, getSeoLinkSnapshot, postSeoLinkAiIndex, postSeoLinkSuggest, postSeoLinkApply, } from './seo/handlers/links.js';
3
+ import { postSeoScan, postSeoAudit, getSeoAudits, getSeoAudit } from './seo/handlers/audits.js';
4
+ import { getSeoIssues, getSeoIssuesExportBrief, getSeoIssue, postSeoIssueResolve, postSeoIssueIgnore, postSeoIssueReopen, } from './seo/handlers/issues-list.js';
5
+ import { postSeoIssuesBatchContentBriefs, getSeoIssueContentBrief, postSeoIssueContentBrief, getSeoIssueContentResearch, postSeoIssueContentResearch, postSeoIssueResearchAndBrief, } from './seo/handlers/issues-briefs.js';
6
+ import { postSeoIssueSuggestFix, postSeoIssueApplyFix, postSeoIssueDismissFix, } from './seo/handlers/issues-fixes.js';
7
+ import { getSeoProposals, postSeoProposalsGenerate, postSeoProposalsBulkApply, } from './seo/handlers/proposals.js';
8
+ import { getSeoOverview, getSeoSearchPerformance, getSeoSearchInsights, } from './seo/handlers/overview.js';
9
+ import { getSeoContent, getSeoContentRecord, putSeoContentRecord } from './seo/handlers/content.js';
10
+ import { getSeoAnalysis, getSeoReadability, getSeoInternalLinks } from './seo/handlers/analysis.js';
11
+ import { getLlmsTxt, getLlmsFullTxt, getMarkdownForPath, getAiCrawlerTrap, getAiCrawlers, } from './seo/handlers/public-files.js';
12
+ export { sortIssuesBySeverity } from './seo/handlers/shared.js';
73
13
  /**
74
- * Content-writing fix strategies (`add-section`, `insert-link`, `fix-link`)
75
- * write a document the route cannot name up front, so the API-key collection
76
- * scope is checked inside `applyIssueFix` once the collection is known. JWT
77
- * sessions already passed the route's `WRITE_ROLES` check and are never denied.
14
+ * SEO HTTP routes. Handlers live under `./seo/handlers` so this file stays a
15
+ * registration list. Request behavior is unchanged.
78
16
  */
79
- function contentWriteGate(session) {
80
- return (collection) => apiKeyCollectionScopeDenial(session, collection, 'update');
81
- }
82
17
  export function registerSeoRoutes(router) {
83
- const db = guardedDb;
84
- // ---------------------------------------------------------------------------
85
- // SEO routes
86
- // ---------------------------------------------------------------------------
87
- router.get('/seo/pages', async (request) => {
88
- try {
89
- const auth = await requireAuth(request);
90
- if (auth.error)
91
- return auth.error;
92
- // Bound the scan: a large site can have tens of thousands of published
93
- // docs, and each carries a full `data` JSONB. Default to a high cap so
94
- // typical sites are unaffected; expose `?limit`/`?offset` so clients can
95
- // page rather than pull everything into memory at once. A full page
96
- // (data.length === limit) signals there may be more.
97
- const params = new URL(request.url).searchParams;
98
- const limit = Math.min(Math.max(Number(params.get('limit')) || 1000, 1), 5000);
99
- const offset = Math.max(Number(params.get('offset')) || 0, 0);
100
- const pages = await db().document.findMany({
101
- where: { deletedAt: null, status: 'PUBLISHED' },
102
- select: {
103
- id: true,
104
- collection: true,
105
- data: true,
106
- updatedAt: true,
107
- structuredData: true,
108
- },
109
- orderBy: { updatedAt: 'desc' },
110
- take: limit,
111
- skip: offset,
112
- });
113
- return json({ data: pages.map(normalizeSeoPage), limit, offset });
114
- }
115
- catch (err) {
116
- return internalError(err);
117
- }
118
- });
119
- router.get('/seo/link-health', async (request) => {
120
- try {
121
- const auth = await requireAuth(request);
122
- if (auth.error)
123
- return auth.error;
124
- // EDITOR+ only — this endpoint hits arbitrary URLs and can be expensive.
125
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
126
- if (roleErr)
127
- return roleErr;
128
- // Tight rate limit because each call fans out to many outbound requests.
129
- const ip = clientIp(request);
130
- const rateKey = isResolvedIp(ip)
131
- ? `link-health:${ip}`
132
- : `link-health-user:${auth.session.userId}`;
133
- if (!(await checkRateLimitAsync(linkHealthLimiter, rateKey))) {
134
- return errorResponse('Too many link-health scans. Please try again later.', 429);
135
- }
136
- const MAX_LINKS_PER_PAGE = 50;
137
- const MAX_TOTAL_LINKS = 500;
138
- const DOC_BATCH_SIZE = 100;
139
- const PER_LINK_TIMEOUT_MS = 4000;
140
- const CONCURRENCY = 8;
141
- const siteSeo = getPrentaConfig()?.seo;
142
- const siteUrl = (siteSeo?.siteUrl ?? process.env.NEXT_PUBLIC_SITE_URL ?? '').replace(/\/+$/, '');
143
- const trailingSlash = resolveTrailingSlashPolicy(siteSeo?.trailingSlash);
144
- const queue = [];
145
- const seenGlobal = new Set();
146
- let docsScanned = 0;
147
- let skip = 0;
148
- scanLoop: while (queue.length < MAX_TOTAL_LINKS) {
149
- const batch = await db().document.findMany({
150
- where: { deletedAt: null, status: 'PUBLISHED' },
151
- select: { id: true, title: true, data: true, collection: true },
152
- orderBy: { updatedAt: 'desc' },
153
- skip,
154
- take: DOC_BATCH_SIZE,
155
- });
156
- if (batch.length === 0)
157
- break;
158
- docsScanned += batch.length;
159
- skip += batch.length;
160
- for (const doc of batch) {
161
- const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
162
- // Walk unescaped string fields — never JSON.stringify (captures `\`
163
- // from `\"` and produces mass false positives).
164
- const urls = extractOutboundUrlsFromDocumentData(doc.data ?? {}, { siteUrl });
165
- let countInPage = 0;
166
- for (const clean of urls) {
167
- if (seenGlobal.has(clean))
168
- continue;
169
- seenGlobal.add(clean);
170
- if (countInPage >= MAX_LINKS_PER_PAGE)
171
- break;
172
- if (queue.length >= MAX_TOTAL_LINKS)
173
- break scanLoop;
174
- const isInternal = !!siteUrl && clean.startsWith(siteUrl);
175
- queue.push({ docId: doc.id, pageTitle, clean, isInternal });
176
- countInPage++;
177
- }
178
- }
179
- }
180
- const linkResults = [];
181
- let cursor = 0;
182
- const worker = async () => {
183
- while (cursor < queue.length) {
184
- const idx = cursor++;
185
- const job = queue[idx];
186
- // GET + browser UA. safeFetch rejects private/loopback IPs and
187
- // disables redirect following so 302→internal can't bypass SSRF.
188
- // Retry once on status 0 — a single timeout was a known false
189
- // positive on otherwise-200 pages.
190
- const probed = await probeOutboundLink(job.clean, {
191
- timeoutMs: PER_LINK_TIMEOUT_MS,
192
- retryUnreachable: true,
193
- });
194
- const kind = classifyLinkHealthIssue(probed.status);
195
- const slashTarget = job.isInternal && !kind ? trailingSlashMismatchTarget(job.clean, trailingSlash) : null;
196
- if (!kind && !slashTarget)
197
- continue;
198
- linkResults.push({
199
- id: `${job.docId}-${idx}`,
200
- page: job.pageTitle,
201
- url: job.clean,
202
- status: kind ? probed.status : 308,
203
- type: job.isInternal ? 'internal' : 'external',
204
- kind: kind ?? 'redirected',
205
- ...(probed.redirectTarget || slashTarget
206
- ? { redirectTarget: probed.redirectTarget ?? slashTarget ?? undefined }
207
- : {}),
208
- });
209
- }
210
- };
211
- await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
212
- return json({
213
- data: {
214
- truncated: queue.length >= MAX_TOTAL_LINKS,
215
- docsScanned,
216
- checked: queue.length,
217
- issues: linkResults,
218
- },
219
- });
220
- }
221
- catch (err) {
222
- return internalError(err);
223
- }
224
- });
225
- router.get('/seo/links/snapshot', async (request) => {
226
- try {
227
- const auth = await requireAuth(request);
228
- if (auth.error)
229
- return auth.error;
230
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
231
- if (roleErr)
232
- return roleErr;
233
- const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
234
- const { buildLinkGraphSnapshot } = await import('../../seo/link-graph.js');
235
- const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
236
- const cfg = getPrentaConfig();
237
- const collectionTypes = frontEndContentCollectionTypes(cfg);
238
- const siteUrl = cfg?.seo?.siteUrl?.replace(/\/+$/, '') ?? '';
239
- const entities = await gatherAuditEntities(db(), { collectionTypes });
240
- const docRows = await db()
241
- .document.findMany({
242
- where: {
243
- deletedAt: null,
244
- status: 'PUBLISHED',
245
- collection: { in: Object.keys(collectionTypes) },
246
- },
247
- select: { id: true, collection: true },
248
- })
249
- .catch(() => []);
250
- const collections = Object.fromEntries(docRows.map((d) => [d.id, d.collection]));
251
- let brokenLinkCount = 0;
252
- if (hasModel(db(), 'seoIssue')) {
253
- brokenLinkCount = await db()
254
- .seoIssue.count({
255
- where: {
256
- status: { in: ['open', 'recurring'] },
257
- type: 'broken-internal-link',
258
- },
259
- })
260
- .catch(() => 0);
261
- }
262
- const overrides = await getSeoOverrides(db()).catch(() => null);
263
- const moduleSettings = resolveSeoModuleSettings(overrides);
264
- // Global nav/footer menus link to top-level pages from site chrome, not
265
- // from document bodies. Feed those targets in so nav-only reachable pages
266
- // are not mis-reported as orphaned. Best-effort: no-op when the navigation
267
- // plugin isn't installed.
268
- const navLinks = await gatherNavigationLinkTargets(db());
269
- const snapshot = buildLinkGraphSnapshot({
270
- entities,
271
- siteUrl,
272
- collections,
273
- brokenLinkCount,
274
- aiIndexLastRunAt: moduleSettings.linkAiIndexLastRunAt,
275
- navLinks,
276
- });
277
- return json({ data: snapshot });
278
- }
279
- catch (err) {
280
- return internalError(err, 'seo/links/snapshot');
281
- }
282
- });
283
- router.post('/seo/links/ai-index', async (request) => {
284
- try {
285
- const auth = await requireAuth(request);
286
- if (auth.error)
287
- return auth.error;
288
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
289
- if (roleErr)
290
- return roleErr;
291
- if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-ai-index:${auth.session.userId}`))) {
292
- return errorResponse('AI link index rate limit reached. Try again later.', 429);
293
- }
294
- const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
295
- const { patchSeoModuleSettings } = await import('../../seo/config-store.js');
296
- const cfg = getPrentaConfig();
297
- const collectionTypes = frontEndContentCollectionTypes(cfg);
298
- const entities = await gatherAuditEntities(db(), { collectionTypes });
299
- // Availability = the governed runtime can serve the link feature right
300
- // now (toggle on, model + key resolvable, budget/cap not exhausted) AND
301
- // the resolved provider has an embeddings API in the runtime. A
302
- // text-only provider (Anthropic, xAI) resolves fine but `embedForFeature`
303
- // would refuse with `no_model`, so suggestions stay on phrase matching.
304
- const { embeddingsAvailableForProvider, resolveFeatureRuntime } = await import('../../ai/index.js');
305
- const probe = await resolveFeatureRuntime(db(), 'ai.features.seoInternalLinks');
306
- const aiAvailable = probe.ok && embeddingsAvailableForProvider(probe.providerKey);
307
- const now = new Date().toISOString();
308
- await patchSeoModuleSettings(db(), { linkAiIndexLastRunAt: now }, auth.session.userId);
309
- await logEvent({
310
- event: 'seo_link_ai_index',
311
- userId: auth.session.userId,
312
- details: { entitiesIndexed: entities.length, aiAvailable },
313
- });
314
- return json({
315
- data: {
316
- indexedAt: now,
317
- entitiesIndexed: entities.length,
318
- aiAvailable,
319
- // No standing index/embeddings are persisted here — /seo/links/suggest
320
- // rebuilds the corpus on demand and works with or without this run.
321
- // Keep the copy honest: this refreshes the link corpus and records the
322
- // run; it does not "build" anything suggestions depend on.
323
- message: aiAvailable
324
- ? 'Link corpus refreshed. AI link suggestions are generated on demand — run them on orphaned or under-linked pages.'
325
- : 'Link corpus refreshed. Connect an AI provider in Settings > AI to enable semantic link suggestions; phrase matching is active meanwhile.',
326
- },
327
- });
328
- }
329
- catch (err) {
330
- return internalError(err, 'seo/links/ai-index');
331
- }
332
- });
333
- router.post('/seo/links/suggest', async (request) => {
334
- try {
335
- const auth = await requireAuth(request);
336
- if (auth.error)
337
- return auth.error;
338
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
339
- if (roleErr)
340
- return roleErr;
341
- if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-suggest:${auth.session.userId}`))) {
342
- return errorResponse('Link suggestion rate limit reached. Try again later.', 429);
343
- }
344
- const body = (await request.json());
345
- if (!body.targetEntityId || !body.targetEntityType) {
346
- return errorResponse('targetEntityId and targetEntityType are required', 400);
347
- }
348
- const limit = Math.max(1, Math.min(20, typeof body.limit === 'number' ? body.limit : 8));
349
- let result;
350
- try {
351
- result = await suggestInboundLinks({
352
- db: db(),
353
- targetEntityId: body.targetEntityId,
354
- targetEntityType: body.targetEntityType,
355
- limit,
356
- userId: auth.session.userId,
357
- });
358
- }
359
- catch (err) {
360
- if (err instanceof Error && err.message === 'TARGET_NOT_FOUND') {
361
- return errorResponse('Target document not found in link graph', 404);
362
- }
363
- throw err;
364
- }
365
- return json({
366
- data: {
367
- target: result.target,
368
- suggestions: result.suggestions,
369
- },
370
- });
371
- }
372
- catch (err) {
373
- return internalError(err, 'seo/links/suggest');
374
- }
375
- });
376
- router.post('/seo/links/apply', async (request) => {
377
- try {
378
- const auth = await requireAuth(request);
379
- if (auth.error)
380
- return auth.error;
381
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
382
- if (roleErr)
383
- return roleErr;
384
- const inlinePlanErr = assertPlanFeature('seo.inlineApply');
385
- if (inlinePlanErr)
386
- return planRequiredResponse(inlinePlanErr);
387
- if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-apply:${auth.session.userId}`))) {
388
- return errorResponse('Link apply rate limit reached. Try again later.', 429);
389
- }
390
- const parsed = await validateBody(request, seoLinkApplyBodySchema);
391
- if (!parsed.ok)
392
- return parsed.error;
393
- const { sourceEntityId, targetEntityId, anchorText, fingerprint } = parsed.data;
394
- const source = await db().document.findFirst({
395
- where: { id: sourceEntityId, deletedAt: null },
396
- select: {
397
- id: true,
398
- collection: true,
399
- updatedAt: true,
400
- data: true,
401
- slug: true,
402
- status: true,
403
- },
404
- });
405
- if (!source)
406
- return errorResponse('Source document not found', 404);
407
- const target = await db().document.findFirst({
408
- where: { id: targetEntityId, deletedAt: null },
409
- select: { id: true, collection: true, data: true, slug: true, status: true },
410
- });
411
- if (!target)
412
- return errorResponse('Target document not found', 404);
413
- const targetData = asRecord(target.data);
414
- const targetSlug = (typeof targetData.slug === 'string' && targetData.slug) ||
415
- (typeof target.slug === 'string' && target.slug) ||
416
- target.id;
417
- const collectionDef = getPrentaConfig()?.collections?.[target.collection];
418
- const targetPath = documentPublicPath({
419
- urlPrefix: collectionDef?.urlPrefix,
420
- slug: targetSlug,
421
- data: targetData,
422
- });
423
- const { applyAndVerifyInternalLinkInsert, buildInsertLinkFingerprint } = await import('../../seo/issue-fix-link-insert.js');
424
- const liveFingerprint = buildInsertLinkFingerprint(sourceEntityId, targetEntityId, anchorText, source.updatedAt instanceof Date ? source.updatedAt : new Date(source.updatedAt));
425
- if (fingerprint !== liveFingerprint) {
426
- return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
427
- }
428
- const ctx = buildActionContext(auth.session, db());
429
- let verified;
430
- try {
431
- const result = await applyAndVerifyInternalLinkInsert({
432
- db: db(),
433
- ctx,
434
- sourceEntityId,
435
- targetEntityId,
436
- anchorText,
437
- targetPath,
438
- siteUrl: getPrentaConfig()?.seo?.siteUrl ?? '',
439
- navLinks: await gatherNavigationLinkTargets(db()),
440
- });
441
- verified = result.verify;
442
- }
443
- catch (err) {
444
- const mapped = mapInternalLinkInsertError(err);
445
- if (mapped)
446
- return mapped;
447
- throw err;
448
- }
449
- if (!verified.ok) {
450
- return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
451
- }
452
- if (hasModel(db(), 'seoIssue')) {
453
- await db().seoIssue.updateMany({
454
- where: {
455
- type: 'orphan-page',
456
- entityId: targetEntityId,
457
- status: { in: ['open', 'recurring'] },
458
- },
459
- data: {
460
- status: 'resolved',
461
- resolvedAt: new Date(),
462
- resolvedById: auth.session.userId,
463
- },
464
- });
465
- }
466
- try {
467
- await logEvent({
468
- event: 'document_updated',
469
- userId: auth.session.userId,
470
- details: {
471
- action: 'seo_internal_link_applied',
472
- seoFix: 'insert-link',
473
- sourceEntityId,
474
- targetEntityId,
475
- targetPath,
476
- },
477
- });
478
- }
479
- catch {
480
- /* noop */
481
- }
482
- return json({
483
- data: { sourceEntityId, targetEntityId, targetPath, applied: true },
484
- });
485
- }
486
- catch (err) {
487
- return internalError(err, 'seo/links/apply');
488
- }
489
- });
490
- router.post('/seo/scan', async (request) => {
491
- try {
492
- const auth = await requireAuth(request);
493
- if (auth.error)
494
- return auth.error;
495
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
496
- if (roleErr)
497
- return roleErr;
498
- // Expensive: loads + regex-scans every published doc. Rate-limit it (same
499
- // budget as the full audit) so it can't be hammered into an OOM/CPU spike.
500
- const scanIp = clientIp(request);
501
- const scanKey = isResolvedIp(scanIp)
502
- ? `seo-scan:${scanIp}`
503
- : `seo-scan-user:${auth.session.userId}`;
504
- if (!(await checkRateLimitAsync(seoAuditLimiter, scanKey))) {
505
- return errorResponse('Too many scans. Please try again later.', 429);
506
- }
507
- // Scope the scan to configured front-end content collections so structural
508
- // (navigation/footer) and benchmark documents are never reported. Shares
509
- // the exact scan logic with the cron path via processSeoScan.
510
- const { frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
511
- const allowedCollections = Object.keys(frontEndContentCollectionTypes(getPrentaConfig()));
512
- const result = await processSeoScan(db(), { allowedCollections, maxDocuments: 5000 });
513
- return json({ data: result });
514
- }
515
- catch (err) {
516
- return internalError(err);
517
- }
518
- });
519
- // ---------------------------------------------------------------------------
520
- // SEO Operations Center — audit engine, issues, overview, content records
521
- // ---------------------------------------------------------------------------
522
- function seoIssueToApi(i) {
523
- return {
524
- id: i.id,
525
- auditRunId: i.auditRunId ?? null,
526
- entityType: i.entityType ?? null,
527
- entityId: i.entityId ?? null,
528
- entityTitle: i.entityTitle ?? null,
529
- url: i.url ?? null,
530
- severity: i.severity,
531
- category: i.category,
532
- type: i.type,
533
- title: i.title,
534
- description: i.description ?? null,
535
- impact: i.impact ?? null,
536
- recommendation: i.recommendation ?? null,
537
- status: i.status,
538
- fixable: i.fixable ?? false,
539
- fixActionType: i.fixActionType ?? null,
540
- relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
541
- source: i.source ?? 'audit',
542
- firstDetectedAt: i.firstDetectedAt ?? null,
543
- lastDetectedAt: i.lastDetectedAt ?? null,
544
- resolvedAt: i.resolvedAt ?? null,
545
- };
546
- }
547
- /**
548
- * Compute the set of front-end content collections the caller may read.
549
- * Mirrors the per-document `enforceDocumentReadAccess` check the sibling
550
- * `/seo/analysis|readability|…` endpoints run, but resolved once for the
551
- * collection list so the aggregate `/seo/content` view can never surface SEO
552
- * fields for a collection the caller's scope/role forbids.
553
- */
554
- async function readableSeoCollections(session) {
555
- const { frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
556
- const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
557
- const allowed = new Set();
558
- for (const slug of Object.keys(collectionTypes)) {
559
- const err = await enforceDocumentReadAccess(session, slug);
560
- if (!err)
561
- allowed.add(slug);
562
- }
563
- return allowed;
564
- }
565
- async function buildSeoContentRecords(allowedCollections,
566
- /**
567
- * Restrict to specific documents, regardless of publication status. Used by
568
- * the per-document GET, which must answer for a saved draft — the list
569
- * endpoints deliberately stay published-only.
570
- */
571
- documentIds, page) {
572
- const { gatherAuditEntities, countAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
573
- const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
574
- const { calculatePageSeoScore } = await import('../../seo/score.js');
575
- const allTypes = frontEndContentCollectionTypes(getPrentaConfig());
576
- // When a readable-collection allow-list is supplied, scope the gather to it
577
- // so records for forbidden collections are never even built.
578
- const collectionTypes = allowedCollections
579
- ? Object.fromEntries(Object.entries(allTypes).filter(([slug]) => allowedCollections.has(slug)))
580
- : allTypes;
581
- const overrides = await getSeoOverrides(db()).catch(() => null);
582
- const siteSeo = applySeoOverrides(getPrentaConfig(), overrides)?.seo;
583
- const [entities, total] = await Promise.all([
584
- gatherAuditEntities(db(), {
585
- collectionTypes,
586
- documentIds,
587
- skip: page?.skip,
588
- take: page?.take,
589
- }),
590
- documentIds
591
- ? Promise.resolve(documentIds.length)
592
- : countAuditEntities(db(), { collectionTypes }),
593
- ]);
594
- const records = entities.map((e) => {
595
- const score = calculatePageSeoScore(e);
596
- return {
597
- id: `${e.entityType}:${e.entityId}`,
598
- entityType: e.entityType,
599
- entityId: e.entityId,
600
- title: e.title,
601
- url: e.url,
602
- canonicalUrl: e.canonicalUrl ?? null,
603
- /** Rendered public title (after site title template). */
604
- metaTitle: e.metaTitle ?? null,
605
- /** Stored page-specific portion (`%page%` token). */
606
- metaTitlePage: e.metaTitlePage ?? null,
607
- siteName: siteSeo?.siteName ?? null,
608
- titleTemplate: siteSeo?.titleTemplate ?? null,
609
- metaDescription: e.metaDescription ?? null,
610
- focusKeyword: e.focusKeyword ?? null,
611
- keyTakeaway: e.keyTakeaway ?? null,
612
- ogTitle: e.ogTitle ?? null,
613
- ogDescription: e.ogDescription ?? null,
614
- ogImageId: null,
615
- ogImage: e.ogImage ?? null,
616
- twitterTitle: null,
617
- twitterDescription: null,
618
- twitterImageId: null,
619
- noindex: e.noindex ?? false,
620
- nofollow: false,
621
- // Permissive defaults; the single-record GET overlays what is stored.
622
- nosnippet: false,
623
- noarchive: false,
624
- maxImagePreview: DEFAULT_DIRECTIVES.maxImagePreview,
625
- structuredDataType: e.structuredDataType ?? null,
626
- structuredData: null,
627
- // llms.txt curation defaults; the single-record GET overlays the real
628
- // stored values (the list endpoint doesn't surface these).
629
- llmsInclude: 'AUTO',
630
- llmsDescription: null,
631
- llmsSection: null,
632
- llmsOptional: false,
633
- seoScore: score.score,
634
- seoGrade: score.grade,
635
- updatedAt: e.updatedAt instanceof Date
636
- ? e.updatedAt.toISOString()
637
- : typeof e.updatedAt === 'string'
638
- ? e.updatedAt
639
- : null,
640
- };
641
- });
642
- return {
643
- records,
644
- total,
645
- truncated: !documentIds && total > records.length,
646
- };
647
- }
648
- /** Synced GSC data (trailing 28 days). Returns null when there is none yet. */
649
- async function loadRealSearchPerformance(limit) {
650
- if (!hasModel(db(), 'searchPageInsight'))
651
- return null;
652
- try {
653
- const rows = await db().searchPageInsight.findMany({
654
- where: { impressions28d: { gt: 0 } },
655
- orderBy: { impressions28d: 'desc' },
656
- take: limit,
657
- });
658
- if (rows.length === 0)
659
- return null;
660
- const ids = rows.map((r) => r.entityId).filter(Boolean);
661
- const titles = new Map();
662
- if (ids.length > 0) {
663
- const docs = await db().document.findMany({
664
- where: { id: { in: ids } },
665
- select: { id: true, title: true },
666
- });
667
- for (const d of docs) {
668
- if (d.title)
669
- titles.set(d.id, d.title);
670
- }
671
- }
672
- return rows.map((r) => ({
673
- url: r.path,
674
- title: (r.entityId && titles.get(r.entityId)) || r.path,
675
- impressions: r.impressions28d,
676
- clicks: r.clicks28d,
677
- ctr: r.impressions28d > 0 ? r.clicks28d / r.impressions28d : 0,
678
- averagePosition: r.avgPosition28d ?? 0,
679
- }));
680
- }
681
- catch {
682
- return null;
683
- }
684
- }
685
- /**
686
- * Synced GSC rows when Search Console is connected; otherwise (or before the
687
- * first sync) an empty list. The admin renders its own "not connected" empty
688
- * state, and MCP / REST consumers must never see fabricated rows.
689
- */
690
- async function loadSearchPerformance(limit = 10, connected = false) {
691
- if (!connected)
692
- return [];
693
- return (await loadRealSearchPerformance(limit)) ?? [];
694
- }
695
- /**
696
- * Site-level 28d totals + prior-window deltas from the insight table for the
697
- * overview metric cards. Position is impressions-weighted. Null when no
698
- * synced data exists (metrics then fall back to the perf-row estimate,
699
- * which is empty until Search Console is connected).
700
- */
701
- async function loadSearchInsightStats() {
702
- if (!hasModel(db(), 'searchPageInsight'))
703
- return null;
704
- try {
705
- const rows = (await db().searchPageInsight.findMany({
706
- where: { OR: [{ impressions28d: { gt: 0 } }, { impressionsPrev28d: { gt: 0 } }] },
707
- select: {
708
- impressions28d: true,
709
- impressionsPrev28d: true,
710
- avgPosition28d: true,
711
- avgPositionPrev28d: true,
712
- },
713
- take: 2000,
714
- }));
715
- if (rows.length === 0)
716
- return null;
717
- let impressions28d = 0;
718
- let impressionsPrev28d = 0;
719
- let posWeight = 0;
720
- let posWeightBase = 0;
721
- let prevPosWeight = 0;
722
- let prevPosWeightBase = 0;
723
- for (const r of rows) {
724
- impressions28d += r.impressions28d;
725
- impressionsPrev28d += r.impressionsPrev28d;
726
- if (r.avgPosition28d !== null && r.impressions28d > 0) {
727
- posWeight += r.avgPosition28d * r.impressions28d;
728
- posWeightBase += r.impressions28d;
729
- }
730
- if (r.avgPositionPrev28d !== null && r.impressionsPrev28d > 0) {
731
- prevPosWeight += r.avgPositionPrev28d * r.impressionsPrev28d;
732
- prevPosWeightBase += r.impressionsPrev28d;
733
- }
734
- }
735
- return {
736
- impressions28d,
737
- impressionsPrev28d,
738
- avgPosition: posWeightBase > 0 ? posWeight / posWeightBase : null,
739
- avgPositionPrev: prevPosWeightBase > 0 ? prevPosWeight / prevPosWeightBase : null,
740
- };
741
- }
742
- catch {
743
- return null;
744
- }
745
- }
746
- router.post('/seo/audit', async (request) => {
747
- try {
748
- const auth = await requireAuth(request);
749
- if (auth.error)
750
- return auth.error;
751
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
752
- if (roleErr)
753
- return roleErr;
754
- if (!hasModel(db(), 'seoAuditRun') || !hasModel(db(), 'seoIssue')) {
755
- return modelNotAvailable('SeoAuditRun');
756
- }
757
- const ip = clientIp(request);
758
- const rateKey = isResolvedIp(ip) ? `seo-audit:${ip}` : `seo-audit-user:${auth.session.userId}`;
759
- if (!(await checkRateLimitAsync(seoAuditLimiter, rateKey))) {
760
- return errorResponse('Too many audits. Please try again later.', 429);
761
- }
762
- const body = (await request.json().catch(() => ({})));
763
- const scope = body?.scope ?? 'full';
764
- const { runAndPersistAudit, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
765
- const run = await runAndPersistAudit(db(), {
766
- scope,
767
- startedById: auth.session.userId,
768
- collectionTypes: frontEndContentCollectionTypes(getPrentaConfig()),
769
- });
770
- try {
771
- await logEvent({
772
- event: 'settings_changed',
773
- userId: auth.session.userId,
774
- details: { action: 'seo_audit', auditRunId: run.id, scope },
775
- });
776
- }
777
- catch {
778
- /* audit-log failures never block */
779
- }
780
- return json({
781
- data: {
782
- id: run.id,
783
- status: run.status,
784
- siteScoreAfter: run.siteScoreAfter,
785
- issuesFound: run.issuesFound,
786
- criticalCount: run.criticalCount,
787
- warningCount: run.warningCount,
788
- infoCount: run.infoCount,
789
- pagesScanned: run.pagesScanned,
790
- renderedAudit: run.renderedAudit,
791
- },
792
- });
793
- }
794
- catch (err) {
795
- return internalError(err, 'seo/audit');
796
- }
797
- });
798
- router.get('/seo/audits', async (request) => {
799
- try {
800
- const auth = await requireAuth(request);
801
- if (auth.error)
802
- return auth.error;
803
- if (!hasModel(db(), 'seoAuditRun'))
804
- return json({ data: { runs: [] } });
805
- const runs = await db().seoAuditRun.findMany({
806
- orderBy: { startedAt: 'desc' },
807
- take: 50,
808
- });
809
- return json({ data: { runs } });
810
- }
811
- catch (err) {
812
- return internalError(err, 'seo/audits');
813
- }
814
- });
815
- router.get('/seo/audits/:id', async (request, params) => {
816
- try {
817
- const auth = await requireAuth(request);
818
- if (auth.error)
819
- return auth.error;
820
- if (!hasModel(db(), 'seoAuditRun'))
821
- return modelNotAvailable('SeoAuditRun');
822
- const run = await db().seoAuditRun.findUnique({ where: { id: params.id } });
823
- if (!run)
824
- return errorResponse('Audit run not found', 404);
825
- return json({ data: run });
826
- }
827
- catch (err) {
828
- return internalError(err, 'seo/audits/:id');
829
- }
830
- });
831
- router.get('/seo/issues', async (request) => {
832
- try {
833
- const auth = await requireAuth(request);
834
- if (auth.error)
835
- return auth.error;
836
- if (!hasModel(db(), 'seoIssue'))
837
- return json({ data: { issues: [] } });
838
- const url = new URL(request.url);
839
- const where = {};
840
- const severity = url.searchParams.get('severity');
841
- const category = url.searchParams.get('category');
842
- const status = url.searchParams.get('status');
843
- const auditRunId = url.searchParams.get('auditRunId');
844
- if (severity)
845
- where.severity = severity;
846
- if (category)
847
- where.category = category;
848
- if (status)
849
- where.status = status;
850
- else
851
- where.status = { in: ['open', 'recurring'] };
852
- if (auditRunId)
853
- where.auditRunId = auditRunId;
854
- const issues = await db().seoIssue.findMany({
855
- where,
856
- orderBy: { lastDetectedAt: 'desc' },
857
- take: 500,
858
- });
859
- return json({ data: { issues: sortIssuesBySeverity(issues).map(seoIssueToApi) } });
860
- }
861
- catch (err) {
862
- return internalError(err, 'seo/issues');
863
- }
864
- });
865
- router.get('/seo/issues/export-brief', async (request) => {
866
- try {
867
- const auth = await requireAuth(request);
868
- if (auth.error)
869
- return auth.error;
870
- if (!hasModel(db(), 'seoIssue')) {
871
- const { buildSeoFixBriefMarkdown, fixBriefFilename } = await import('../../seo/fix-brief-export.js');
872
- return json({
873
- data: {
874
- markdown: buildSeoFixBriefMarkdown({ issues: [], manualOnly: true }),
875
- filename: fixBriefFilename(),
876
- issueCount: 0,
877
- },
878
- });
879
- }
880
- const url = new URL(request.url);
881
- const severity = url.searchParams.get('severity');
882
- const status = url.searchParams.get('status');
883
- const auditRunId = url.searchParams.get('auditRunId');
884
- const manualOnly = url.searchParams.get('manualOnly') !== 'false';
885
- const includeContentBriefs = url.searchParams.get('includeContentBriefs') !== 'false';
886
- const includeContentResearch = url.searchParams.get('includeContentResearch') !== 'false';
887
- const where = {};
888
- if (severity)
889
- where.severity = severity;
890
- if (status)
891
- where.status = status;
892
- else
893
- where.status = { in: ['open', 'recurring'] };
894
- if (auditRunId)
895
- where.auditRunId = auditRunId;
896
- const issues = sortIssuesBySeverity(await db().seoIssue.findMany({
897
- where,
898
- orderBy: { lastDetectedAt: 'desc' },
899
- take: 500,
900
- }));
901
- let runAuditDate = null;
902
- let runId = auditRunId;
903
- if (auditRunId && hasModel(db(), 'seoAuditRun')) {
904
- const run = await db().seoAuditRun.findUnique({ where: { id: auditRunId } });
905
- runAuditDate = run?.finishedAt ?? run?.startedAt ?? null;
906
- }
907
- else if (hasModel(db(), 'seoAuditRun')) {
908
- const lastRun = await db().seoAuditRun.findFirst({
909
- where: { status: 'COMPLETED' },
910
- orderBy: { startedAt: 'desc' },
911
- });
912
- runId = lastRun?.id ?? null;
913
- runAuditDate = lastRun?.finishedAt ?? lastRun?.startedAt ?? null;
914
- }
915
- const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
916
- const overrides = await getSeoOverrides(db()).catch(() => null);
917
- const mergedConfig = applySeoOverrides(getPrentaConfig(), overrides);
918
- const siteSeo = mergedConfig?.seo;
919
- const { buildSeoFixBriefMarkdown, filterFixBriefIssues, fixBriefFilename } = await import('../../seo/fix-brief-export.js');
920
- const entityIds = [
921
- ...new Set(issues
922
- .map((i) => i.entityId)
923
- .filter((id) => typeof id === 'string' && id.length > 0)),
924
- ];
925
- const updatedAtByEntity = new Map();
926
- if ((includeContentBriefs || includeContentResearch) && entityIds.length > 0) {
927
- const docs = await db().document.findMany({
928
- where: { id: { in: entityIds }, deletedAt: null },
929
- select: { id: true, updatedAt: true },
930
- });
931
- for (const doc of docs) {
932
- updatedAtByEntity.set(doc.id, doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt));
933
- }
934
- }
935
- const { contentBriefFromMetadata } = await import('../../seo/issue-content-brief.js');
936
- const { contentResearchFromMetadata } = await import('../../seo/issue-content-research.js');
937
- const briefIssues = issues.map((i) => {
938
- let contentBrief = null;
939
- let contentResearch = null;
940
- if (i.entityId) {
941
- const updatedAt = updatedAtByEntity.get(i.entityId);
942
- if (updatedAt) {
943
- if (includeContentBriefs) {
944
- contentBrief = contentBriefFromMetadata(i.metadata, i.type, i.entityId, updatedAt);
945
- }
946
- if (includeContentResearch) {
947
- contentResearch = contentResearchFromMetadata(i.metadata, i.type, i.entityId, updatedAt);
948
- }
949
- }
950
- }
951
- return {
952
- id: i.id,
953
- type: i.type,
954
- title: i.title,
955
- description: i.description ?? null,
956
- impact: i.impact ?? null,
957
- recommendation: i.recommendation ?? null,
958
- severity: i.severity,
959
- category: i.category,
960
- status: i.status,
961
- entityType: i.entityType ?? null,
962
- entityId: i.entityId ?? null,
963
- entityTitle: i.entityTitle ?? null,
964
- url: i.url ?? null,
965
- fixActionType: i.fixActionType ?? null,
966
- fixable: i.fixable ?? false,
967
- contentBrief,
968
- contentResearch,
969
- };
970
- });
971
- const markdown = buildSeoFixBriefMarkdown({
972
- siteName: siteSeo?.siteName ?? null,
973
- siteUrl: siteSeo?.siteUrl ?? null,
974
- auditRunId: runId,
975
- auditDate: runAuditDate,
976
- issues: briefIssues,
977
- manualOnly,
978
- });
979
- const issueCount = filterFixBriefIssues(briefIssues, manualOnly).length;
980
- return json({
981
- data: {
982
- markdown,
983
- filename: fixBriefFilename(siteSeo?.siteName, runAuditDate ?? undefined),
984
- issueCount,
985
- },
986
- });
987
- }
988
- catch (err) {
989
- return internalError(err, 'seo/issues/export-brief');
990
- }
991
- });
992
- router.post('/seo/issues/batch-content-briefs', async (request) => {
993
- try {
994
- const auth = await requireAuth(request);
995
- if (auth.error)
996
- return auth.error;
997
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
998
- if (roleErr)
999
- return roleErr;
1000
- const batchPlanErr = assertPlanFeature('seo.batchContentBriefs');
1001
- if (batchPlanErr)
1002
- return planRequiredResponse(batchPlanErr);
1003
- if (!hasModel(db(), 'seoIssue'))
1004
- return modelNotAvailable('SeoIssue');
1005
- if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-batch-briefs:${auth.session.userId}`))) {
1006
- return errorResponse('AI rate limit reached. Try again in an hour.', 429);
1007
- }
1008
- const body = (await request.json().catch(() => ({})));
1009
- const severity = typeof body?.severity === 'string' ? body.severity : null;
1010
- const status = typeof body?.status === 'string' ? body.status : null;
1011
- const skipExisting = body?.skipExisting !== false;
1012
- const includeResearch = body?.includeResearch === true;
1013
- const competitorUrls = Array.isArray(body?.competitorUrls)
1014
- ? body.competitorUrls.filter((u) => typeof u === 'string')
1015
- : undefined;
1016
- const issueIds = Array.isArray(body?.issueIds)
1017
- ? body.issueIds.filter((id) => typeof id === 'string' && id.length > 0)
1018
- : null;
1019
- const where = {};
1020
- if (severity)
1021
- where.severity = severity;
1022
- if (status)
1023
- where.status = status;
1024
- else
1025
- where.status = { in: ['open', 'recurring'] };
1026
- let issues = await db().seoIssue.findMany({
1027
- where,
1028
- orderBy: { lastDetectedAt: 'desc' },
1029
- take: 500,
1030
- });
1031
- if (issueIds?.length) {
1032
- const idSet = new Set(issueIds);
1033
- issues = issues.filter((i) => idSet.has(i.id));
1034
- }
1035
- const { supportsContentBrief } = await import('../../seo/issue-content-brief.js');
1036
- const eligible = issues.filter((i) => supportsContentBrief(i.type));
1037
- const entityIds = [
1038
- ...new Set(eligible
1039
- .map((i) => i.entityId)
1040
- .filter((id) => typeof id === 'string' && id.length > 0)),
1041
- ];
1042
- const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
1043
- const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
1044
- const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
1045
- const overrides = await getSeoOverrides(db()).catch(() => null);
1046
- const mergedConfig = applySeoOverrides(getPrentaConfig(), overrides);
1047
- const contexts = new Map();
1048
- if (entityIds.length > 0) {
1049
- const docs = await db().document.findMany({
1050
- where: { id: { in: entityIds }, deletedAt: null },
1051
- select: {
1052
- id: true,
1053
- collection: true,
1054
- title: true,
1055
- slug: true,
1056
- data: true,
1057
- structuredData: true,
1058
- plainText: true,
1059
- publishedAt: true,
1060
- updatedAt: true,
1061
- },
1062
- });
1063
- for (const doc of docs) {
1064
- contexts.set(doc.id, {
1065
- collection: doc.collection,
1066
- entity: documentToAuditEntity(doc, collectionTypes, mergedConfig?.seo),
1067
- updatedAt: doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt),
1068
- });
1069
- }
1070
- }
1071
- const { batchGenerateContentBriefs } = await import('../../seo/issue-batch-briefs.js');
1072
- const result = await batchGenerateContentBriefs(eligible.map((i) => ({
1073
- id: i.id,
1074
- type: i.type,
1075
- title: i.title,
1076
- description: i.description,
1077
- recommendation: i.recommendation,
1078
- entityId: i.entityId,
1079
- entityType: i.entityType,
1080
- metadata: i.metadata,
1081
- })), contexts, { db: db(), userId: auth.session.userId, collection: 'pages' }, { skipExisting, includeResearch, competitorUrls });
1082
- return json({ data: result });
1083
- }
1084
- catch (err) {
1085
- return internalError(err, 'seo/issues/batch-content-briefs');
1086
- }
1087
- });
1088
- router.get('/seo/issues/:id/content-brief', async (request, params) => {
1089
- try {
1090
- const auth = await requireAuth(request);
1091
- if (auth.error)
1092
- return auth.error;
1093
- if (!hasModel(db(), 'seoIssue'))
1094
- return modelNotAvailable('SeoIssue');
1095
- const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
1096
- if (!issue)
1097
- return errorResponse('Issue not found', 404);
1098
- const { supportsContentBrief, contentBriefFromMetadata } = await import('../../seo/issue-content-brief.js');
1099
- if (!supportsContentBrief(issue.type)) {
1100
- return json({ data: { supported: false, brief: null, stale: false } });
1101
- }
1102
- if (!issue.entityId) {
1103
- return json({ data: { supported: true, brief: null, stale: false } });
1104
- }
1105
- const loaded = await loadIssueFixContext(issue);
1106
- if ('error' in loaded)
1107
- return loaded.error;
1108
- const brief = contentBriefFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt);
1109
- const raw = asRecord(issue.metadata)?.contentBrief;
1110
- const stale = !!raw &&
1111
- typeof raw === 'object' &&
1112
- !Array.isArray(raw) &&
1113
- typeof raw.fingerprint === 'string' &&
1114
- !brief;
1115
- return json({ data: { supported: true, brief, stale } });
1116
- }
1117
- catch (err) {
1118
- return internalError(err, 'seo/issues/:id/content-brief');
1119
- }
1120
- });
1121
- router.post('/seo/issues/:id/content-brief', async (request, params) => {
1122
- try {
1123
- const auth = await requireAuth(request);
1124
- if (auth.error)
1125
- return auth.error;
1126
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
1127
- if (roleErr)
1128
- return roleErr;
1129
- const planErr = assertPlanFeature('seo.contentBrief');
1130
- if (planErr)
1131
- return planRequiredResponse(planErr);
1132
- if (!hasModel(db(), 'seoIssue'))
1133
- return modelNotAvailable('SeoIssue');
1134
- if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-content-brief:${auth.session.userId}`))) {
1135
- return errorResponse('AI rate limit reached. Try again in an hour.', 429);
1136
- }
1137
- const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
1138
- if (!issue)
1139
- return errorResponse('Issue not found', 404);
1140
- const loaded = await loadIssueFixContext(issue);
1141
- if ('error' in loaded)
1142
- return loaded.error;
1143
- const { generateContentBrief, contentBriefToMetadata, supportsContentBrief } = await import('../../seo/issue-content-brief.js');
1144
- if (!supportsContentBrief(issue.type)) {
1145
- return errorResponse('This issue type does not support content briefs.', 422);
1146
- }
1147
- const result = await generateContentBrief({
1148
- id: issue.id,
1149
- type: issue.type,
1150
- title: issue.title,
1151
- description: issue.description,
1152
- recommendation: issue.recommendation,
1153
- entityId: issue.entityId,
1154
- }, { entity: loaded.entity, updatedAt: loaded.updatedAt }, { db: db(), userId: auth.session.userId, collection: loaded.collection });
1155
- if (!result.ok) {
1156
- return seoAiGenerationError(result);
1157
- }
1158
- const existingMeta = asRecord(issue.metadata);
1159
- await db().seoIssue.update({
1160
- where: { id: issue.id },
1161
- data: { metadata: { ...existingMeta, ...contentBriefToMetadata(result.brief) } },
1162
- });
1163
- return json({ data: { brief: result.brief } });
1164
- }
1165
- catch (err) {
1166
- return internalError(err, 'seo/issues/:id/content-brief');
1167
- }
1168
- });
1169
- router.get('/seo/issues/:id/content-research', async (request, params) => {
1170
- try {
1171
- const auth = await requireAuth(request);
1172
- if (auth.error)
1173
- return auth.error;
1174
- if (!hasModel(db(), 'seoIssue'))
1175
- return modelNotAvailable('SeoIssue');
1176
- const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
1177
- if (!issue)
1178
- return errorResponse('Issue not found', 404);
1179
- const { supportsContentResearch, contentResearchFromMetadata } = await import('../../seo/issue-content-research.js');
1180
- if (!supportsContentResearch(issue.type)) {
1181
- return json({ data: { supported: false, research: null, stale: false } });
1182
- }
1183
- if (!issue.entityId) {
1184
- return json({ data: { supported: true, research: null, stale: false } });
1185
- }
1186
- const loaded = await loadIssueFixContext(issue);
1187
- if ('error' in loaded)
1188
- return loaded.error;
1189
- const research = contentResearchFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt);
1190
- const raw = asRecord(issue.metadata)?.contentResearch;
1191
- const stale = !!raw &&
1192
- typeof raw === 'object' &&
1193
- !Array.isArray(raw) &&
1194
- typeof raw.fingerprint === 'string' &&
1195
- !research;
1196
- return json({ data: { supported: true, research, stale } });
1197
- }
1198
- catch (err) {
1199
- return internalError(err, 'seo/issues/:id/content-research');
1200
- }
1201
- });
1202
- router.post('/seo/issues/:id/content-research', async (request, params) => {
1203
- try {
1204
- const auth = await requireAuth(request);
1205
- if (auth.error)
1206
- return auth.error;
1207
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
1208
- if (roleErr)
1209
- return roleErr;
1210
- const researchPlanErr = assertPlanFeature('seo.contentResearch');
1211
- if (researchPlanErr)
1212
- return planRequiredResponse(researchPlanErr);
1213
- if (!hasModel(db(), 'seoIssue'))
1214
- return modelNotAvailable('SeoIssue');
1215
- if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-content-research:${auth.session.userId}`))) {
1216
- return errorResponse('AI rate limit reached. Try again in an hour.', 429);
1217
- }
1218
- const body = (await request.json().catch(() => ({})));
1219
- const competitorUrls = Array.isArray(body?.competitorUrls)
1220
- ? body.competitorUrls.filter((u) => typeof u === 'string')
1221
- : undefined;
1222
- const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
1223
- if (!issue)
1224
- return errorResponse('Issue not found', 404);
1225
- const loaded = await loadIssueFixContext(issue);
1226
- if ('error' in loaded)
1227
- return loaded.error;
1228
- const { generateContentResearch, contentResearchToMetadata, supportsContentResearch } = await import('../../seo/issue-content-research.js');
1229
- if (!supportsContentResearch(issue.type)) {
1230
- return errorResponse('This issue type does not support topic research.', 422);
1231
- }
1232
- const result = await generateContentResearch({
1233
- id: issue.id,
1234
- type: issue.type,
1235
- title: issue.title,
1236
- description: issue.description,
1237
- recommendation: issue.recommendation,
1238
- entityId: issue.entityId,
1239
- }, { entity: loaded.entity, updatedAt: loaded.updatedAt }, { db: db(), userId: auth.session.userId, collection: loaded.collection }, { competitorUrls });
1240
- if (!result.ok) {
1241
- return seoAiGenerationError(result);
1242
- }
1243
- const existingMeta = asRecord(issue.metadata);
1244
- await db().seoIssue.update({
1245
- where: { id: issue.id },
1246
- data: { metadata: { ...existingMeta, ...contentResearchToMetadata(result.research) } },
1247
- });
1248
- return json({ data: { research: result.research } });
1249
- }
1250
- catch (err) {
1251
- return internalError(err, 'seo/issues/:id/content-research');
1252
- }
1253
- });
1254
- router.post('/seo/issues/:id/research-and-brief', async (request, params) => {
1255
- try {
1256
- const auth = await requireAuth(request);
1257
- if (auth.error)
1258
- return auth.error;
1259
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
1260
- if (roleErr)
1261
- return roleErr;
1262
- const comboPlanErr = assertPlanFeature('seo.contentResearch');
1263
- if (comboPlanErr)
1264
- return planRequiredResponse(comboPlanErr);
1265
- if (!hasModel(db(), 'seoIssue'))
1266
- return modelNotAvailable('SeoIssue');
1267
- if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-research-brief:${auth.session.userId}`))) {
1268
- return errorResponse('AI rate limit reached. Try again in an hour.', 429);
1269
- }
1270
- const body = (await request.json().catch(() => ({})));
1271
- const competitorUrls = Array.isArray(body?.competitorUrls)
1272
- ? body.competitorUrls.filter((u) => typeof u === 'string')
1273
- : undefined;
1274
- const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
1275
- if (!issue)
1276
- return errorResponse('Issue not found', 404);
1277
- const loaded = await loadIssueFixContext(issue);
1278
- if ('error' in loaded)
1279
- return loaded.error;
1280
- const { generateContentResearch, contentResearchToMetadata, supportsContentResearch, contentResearchFromMetadata, } = await import('../../seo/issue-content-research.js');
1281
- const { generateContentBrief, contentBriefToMetadata, supportsContentBrief } = await import('../../seo/issue-content-brief.js');
1282
- if (!supportsContentBrief(issue.type)) {
1283
- return errorResponse('This issue type does not support content briefs.', 422);
1284
- }
1285
- const issueInput = {
1286
- id: issue.id,
1287
- type: issue.type,
1288
- title: issue.title,
1289
- description: issue.description,
1290
- recommendation: issue.recommendation,
1291
- entityId: issue.entityId,
1292
- };
1293
- const briefCtx = { entity: loaded.entity, updatedAt: loaded.updatedAt };
1294
- const deps = { db: db(), userId: auth.session.userId, collection: loaded.collection };
1295
- let research = supportsContentResearch(issue.type)
1296
- ? contentResearchFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt)
1297
- : null;
1298
- if (supportsContentResearch(issue.type) && !research) {
1299
- const researchResult = await generateContentResearch(issueInput, briefCtx, deps, {
1300
- competitorUrls,
1301
- });
1302
- if (!researchResult.ok) {
1303
- return seoAiGenerationError(researchResult);
1304
- }
1305
- research = researchResult.research;
1306
- }
1307
- const briefResult = await generateContentBrief(issueInput, briefCtx, deps, { research });
1308
- if (!briefResult.ok) {
1309
- return seoAiGenerationError(briefResult);
1310
- }
1311
- const existingMeta = asRecord(issue.metadata);
1312
- const metadata = {
1313
- ...existingMeta,
1314
- ...contentBriefToMetadata(briefResult.brief),
1315
- ...(research ? contentResearchToMetadata(research) : {}),
1316
- };
1317
- await db().seoIssue.update({
1318
- where: { id: issue.id },
1319
- data: { metadata },
1320
- });
1321
- return json({ data: { research, brief: briefResult.brief } });
1322
- }
1323
- catch (err) {
1324
- return internalError(err, 'seo/issues/:id/research-and-brief');
1325
- }
1326
- });
1327
- router.get('/seo/issues/:id', async (request, params) => {
1328
- try {
1329
- const auth = await requireAuth(request);
1330
- if (auth.error)
1331
- return auth.error;
1332
- if (!hasModel(db(), 'seoIssue'))
1333
- return modelNotAvailable('SeoIssue');
1334
- const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
1335
- if (!issue)
1336
- return errorResponse('Issue not found', 404);
1337
- return json({ data: seoIssueToApi(issue) });
1338
- }
1339
- catch (err) {
1340
- return internalError(err, 'seo/issues/:id');
1341
- }
1342
- });
1343
- async function setIssueStatus(request, issueId, status) {
1344
- const auth = await requireAuth(request);
1345
- if (auth.error)
1346
- return auth.error;
1347
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1348
- if (roleErr)
1349
- return roleErr;
1350
- if (!hasModel(db(), 'seoIssue'))
1351
- return modelNotAvailable('SeoIssue');
1352
- const existing = await db().seoIssue.findUnique({ where: { id: issueId } });
1353
- if (!existing)
1354
- return errorResponse('Issue not found', 404);
1355
- const issue = await db().seoIssue.update({
1356
- where: { id: issueId },
1357
- data: {
1358
- status,
1359
- resolvedAt: status === 'resolved' ? new Date() : null,
1360
- resolvedById: status === 'resolved' ? auth.session.userId : null,
1361
- },
1362
- });
1363
- try {
1364
- await logEvent({
1365
- event: 'settings_changed',
1366
- userId: auth.session.userId,
1367
- details: { action: 'seo_issue_status', issueId, status },
1368
- });
1369
- }
1370
- catch {
1371
- /* noop */
1372
- }
1373
- return json({ data: seoIssueToApi(issue) });
1374
- }
1375
- router.post('/seo/issues/:id/resolve', (request, params) => setIssueStatus(request, params.id, 'resolved').catch((e) => internalError(e)));
1376
- router.post('/seo/issues/:id/ignore', (request, params) => setIssueStatus(request, params.id, 'ignored').catch((e) => internalError(e)));
1377
- router.post('/seo/issues/:id/reopen', (request, params) => setIssueStatus(request, params.id, 'open').catch((e) => internalError(e)));
1378
- /** Load the audit entity + collection for an issue's linked document. */
1379
- async function loadIssueFixContext(issue) {
1380
- const { loadIssueFixContext: load } = await import('../../seo/issue-fix-apply.js');
1381
- const loaded = await load(db(), issue);
1382
- if (!loaded.ok)
1383
- return { error: errorResponse(loaded.message, loaded.status) };
1384
- return loaded.ctx;
1385
- }
1386
- router.post('/seo/issues/:id/suggest-fix', async (request, params) => {
1387
- try {
1388
- const auth = await requireAuth(request);
1389
- if (auth.error)
1390
- return auth.error;
1391
- if (!hasModel(db(), 'seoIssue'))
1392
- return modelNotAvailable('SeoIssue');
1393
- const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
1394
- if (!issue)
1395
- return errorResponse('Issue not found', 404);
1396
- const loaded = await loadIssueFixContext(issue);
1397
- if ('error' in loaded)
1398
- return loaded.error;
1399
- const issueInput = {
1400
- type: issue.type,
1401
- title: issue.title,
1402
- recommendation: issue.recommendation,
1403
- entityType: issue.entityType,
1404
- entityId: issue.entityId,
1405
- };
1406
- const fixCtx = {
1407
- entity: loaded.entity,
1408
- siteSeo: loaded.siteSeo,
1409
- updatedAt: loaded.updatedAt,
1410
- metadata: asRecord(issue.metadata),
1411
- };
1412
- let aiDeps;
1413
- const { REDIRECT_FIX_TYPES } = await import('../../seo/issue-fix-redirect.js');
1414
- const { INSERT_LINK_FIX_TYPES } = await import('../../seo/issue-fix-link-insert.js');
1415
- if (REDIRECT_FIX_TYPES.has(issue.type) || INSERT_LINK_FIX_TYPES.has(issue.type)) {
1416
- aiDeps = {
1417
- db: db(),
1418
- userId: auth.session.userId,
1419
- collection: loaded.collection,
1420
- };
1421
- }
1422
- else {
1423
- const adminErr = requireRole(auth.session.role, ADMIN_ROLES);
1424
- if (!adminErr &&
1425
- hasPlanFeature('seo.inlineApply') &&
1426
- (await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-fix-suggest:${auth.session.userId}`))) {
1427
- aiDeps = {
1428
- db: db(),
1429
- userId: auth.session.userId,
1430
- collection: loaded.collection,
1431
- };
1432
- }
1433
- }
1434
- const { suggestIssueFix } = await import('../../seo/issue-fix.js');
1435
- const { pendingFixToMetadata, scoreFixBrandAlignment } = await import('../../seo/issue-fix-ai.js');
1436
- const result = await suggestIssueFix(issueInput, fixCtx, aiDeps);
1437
- const existingMeta = asRecord(issue.metadata);
1438
- let nextMetadata;
1439
- if (result.cacheForApply) {
1440
- // Deterministic brand-voice score stamped at generation (no LLM call)
1441
- // so the proposals inbox can badge it without re-scoring.
1442
- const { getAIConfig } = await import('../../ai/config-store.js');
1443
- const brandVoice = await getAIConfig(db())
1444
- .then((c) => c.settings.brandVoice)
1445
- .catch((err) => {
1446
- // Fail open: an unreadable AI config must not cost a working suggestion.
1447
- seoLogger.warn('AI config unavailable for brand alignment', {
1448
- error: err instanceof Error ? err.message : String(err),
1449
- });
1450
- return undefined;
1451
- });
1452
- nextMetadata = {
1453
- ...existingMeta,
1454
- ...pendingFixToMetadata(result.suggestion, {
1455
- brandAlignment: scoreFixBrandAlignment(result.suggestion.changes, brandVoice),
1456
- }),
1457
- };
1458
- }
1459
- else {
1460
- nextMetadata = { ...existingMeta, pendingFix: null };
1461
- }
1462
- await db().seoIssue.update({
1463
- where: { id: issue.id },
1464
- data: { metadata: nextMetadata },
1465
- });
1466
- return json({ data: { suggestion: result.suggestion } });
1467
- }
1468
- catch (err) {
1469
- return internalError(err, 'seo/issues/:id/suggest-fix');
1470
- }
1471
- });
1472
- router.post('/seo/issues/:id/apply-fix', async (request, params) => {
1473
- try {
1474
- const auth = await requireAuth(request);
1475
- if (auth.error)
1476
- return auth.error;
1477
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1478
- if (roleErr)
1479
- return roleErr;
1480
- if (!hasModel(db(), 'seoIssue'))
1481
- return modelNotAvailable('SeoIssue');
1482
- const parsed = await validateBody(request, seoApplyFixBodySchema);
1483
- if (!parsed.ok)
1484
- return parsed.error;
1485
- const body = parsed.data;
1486
- const [{ applyIssueFix, VERIFY_FAILED_MESSAGE }, { FAQ_HEADING_MAX, FAQ_MAX_ITEMS }] = await Promise.all([
1487
- import('../../seo/issue-fix-apply.js'),
1488
- import('../../seo/issue-fix-faq.js'),
1489
- ]);
1490
- const fingerprint = typeof body?.fingerprint === 'string' ? body.fingerprint : undefined;
1491
- // `includeItems` / `headingOverride` only mean something for `add-section`
1492
- // fixes; they are validated for shape here and ignored by other branches.
1493
- let includeItems;
1494
- if (body?.includeItems !== undefined) {
1495
- const raw = body.includeItems;
1496
- if (!Array.isArray(raw) ||
1497
- raw.length > FAQ_MAX_ITEMS ||
1498
- !raw.every((n) => typeof n === 'number' && Number.isInteger(n) && n >= 0) ||
1499
- new Set(raw).size !== raw.length) {
1500
- return errorResponse(`includeItems must be an array of at most ${FAQ_MAX_ITEMS} distinct non-negative integers`, 400);
1501
- }
1502
- includeItems = raw;
1503
- }
1504
- let headingOverride;
1505
- if (body?.headingOverride !== undefined) {
1506
- // Trimmed length is the one that lands in the section, so it is the one measured.
1507
- const trimmed = typeof body.headingOverride === 'string' ? body.headingOverride.trim() : null;
1508
- if (trimmed === null || trimmed.length > FAQ_HEADING_MAX) {
1509
- return errorResponse(`headingOverride must be a string of at most ${FAQ_HEADING_MAX} characters`, 400);
1510
- }
1511
- headingOverride = trimmed;
1512
- }
1513
- const result = await applyIssueFix(db(), {
1514
- issueId: params.id,
1515
- fingerprint,
1516
- includeItems,
1517
- headingOverride,
1518
- actor: { userId: auth.session.userId, role: auth.session.role },
1519
- authorizeContentWrite: contentWriteGate(auth.session),
1520
- });
1521
- switch (result.status) {
1522
- case 'applied':
1523
- return json({
1524
- data: {
1525
- issue: seoIssueToApi(result.issue),
1526
- applied: result.applied,
1527
- ...(result.sectionId ? { sectionId: result.sectionId } : {}),
1528
- },
1529
- });
1530
- case 'stale':
1531
- return errorResponse(result.reason, 409);
1532
- case 'verify-failed':
1533
- return json({ error: VERIFY_FAILED_MESSAGE, reason: result.reason }, 409);
1534
- case 'plan-required':
1535
- return planRequiredResponse(result.denial);
1536
- case 'not-found':
1537
- return errorResponse(result.reason, 404);
1538
- case 'not-allowed':
1539
- return errorResponse(result.reason, result.httpStatus);
1540
- case 'not-fixable':
1541
- return errorResponse(result.reason, result.httpStatus);
1542
- default: {
1543
- const _exhaustive = result;
1544
- return _exhaustive;
1545
- }
1546
- }
1547
- }
1548
- catch (err) {
1549
- return internalError(err, 'seo/issues/:id/apply-fix');
1550
- }
1551
- });
1552
- router.post('/seo/issues/:id/dismiss-fix', async (request, params) => {
1553
- try {
1554
- const auth = await requireAuth(request);
1555
- if (auth.error)
1556
- return auth.error;
1557
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1558
- if (roleErr)
1559
- return roleErr;
1560
- if (!hasModel(db(), 'seoIssue'))
1561
- return modelNotAvailable('SeoIssue');
1562
- const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
1563
- if (!issue)
1564
- return errorResponse('Issue not found', 404);
1565
- const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
1566
- const cached = pendingFixFromMetadata(issue.metadata);
1567
- if (!cached)
1568
- return json({ data: { dismissed: false } });
1569
- // Sticky: remember which document state was rejected so batch
1570
- // generation does not recreate it until the document changes. The
1571
- // marker must match the entity-based live fingerprint the no-cache
1572
- // path computes, which differs from the cached one for insert-link.
1573
- const { dismissalFingerprintFor } = await import('../../seo/proposals.js');
1574
- const entityDoc = typeof issue.entityId === 'string'
1575
- ? await db()
1576
- .document.findUnique({ where: { id: issue.entityId }, select: { updatedAt: true } })
1577
- .catch(() => null)
1578
- : null;
1579
- const dismissedFingerprint = dismissalFingerprintFor(issue, cached, entityDoc?.updatedAt ?? null);
1580
- await db().seoIssue.update({
1581
- where: { id: issue.id },
1582
- data: {
1583
- metadata: {
1584
- ...asRecord(issue.metadata),
1585
- pendingFix: null,
1586
- dismissedFingerprint,
1587
- },
1588
- },
1589
- });
1590
- try {
1591
- await logEvent({
1592
- event: 'seo_fix_dismissed',
1593
- userId: auth.session.userId,
1594
- details: {
1595
- issueId: issue.id,
1596
- issueType: issue.type,
1597
- entityId: issue.entityId ?? null,
1598
- fingerprint: dismissedFingerprint,
1599
- source: cached.source,
1600
- },
1601
- });
1602
- }
1603
- catch {
1604
- /* audit-log failures never block */
1605
- }
1606
- return json({ data: { dismissed: true } });
1607
- }
1608
- catch (err) {
1609
- return internalError(err, 'seo/issues/:id/dismiss-fix');
1610
- }
1611
- });
1612
- // Any authenticated user, like GET /seo/issues and GET /seo/overview: editors
1613
- // read the inbox and act through the WRITE_ROLES mutation routes.
1614
- router.get('/seo/proposals', async (request) => {
1615
- try {
1616
- const auth = await requireAuth(request);
1617
- if (auth.error)
1618
- return auth.error;
1619
- const { listSeoProposals } = await import('../../seo/proposals.js');
1620
- return json({ data: await listSeoProposals(db()) });
1621
- }
1622
- catch (err) {
1623
- return internalError(err, 'seo/proposals');
1624
- }
1625
- });
1626
- router.post('/seo/proposals/generate', async (request) => {
1627
- try {
1628
- const auth = await requireAuth(request);
1629
- if (auth.error)
1630
- return auth.error;
1631
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
1632
- if (roleErr)
1633
- return roleErr;
1634
- const planErr = assertPlanFeature('seo.inlineApply');
1635
- if (planErr)
1636
- return planRequiredResponse(planErr);
1637
- if (!hasModel(db(), 'seoIssue'))
1638
- return modelNotAvailable('SeoIssue');
1639
- const body = (await request.json().catch(() => ({})));
1640
- if (body?.confirm !== true) {
1641
- return errorResponse('Pass { "confirm": true } to generate proposals.', 400);
1642
- }
1643
- if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-proposals-generate:${auth.session.userId}`))) {
1644
- return errorResponse('AI rate limit reached. Try again in an hour.', 429);
1645
- }
1646
- const { generateSeoProposals } = await import('../../seo/proposals-generate.js');
1647
- const result = await generateSeoProposals(db(), { userId: auth.session.userId });
1648
- return json({ data: result });
1649
- }
1650
- catch (err) {
1651
- return internalError(err, 'seo/proposals/generate');
1652
- }
1653
- });
1654
- // WRITE_ROLES like the per-item apply-fix route; redirect items are
1655
- // additionally admin-gated per item inside the service (spec §4).
1656
- router.post('/seo/proposals/bulk-apply', async (request) => {
1657
- try {
1658
- const auth = await requireAuth(request);
1659
- if (auth.error)
1660
- return auth.error;
1661
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1662
- if (roleErr)
1663
- return roleErr;
1664
- if (!hasModel(db(), 'seoIssue'))
1665
- return modelNotAvailable('SeoIssue');
1666
- const parsed = await validateBody(request, seoBulkApplyBodySchema);
1667
- if (!parsed.ok)
1668
- return parsed.error;
1669
- const rawItems = parsed.data.items;
1670
- const { PROPOSAL_BULK_APPLY_MAX, bulkApplySeoProposals } = await import('../../seo/proposals-bulk-apply.js');
1671
- if (rawItems.length > PROPOSAL_BULK_APPLY_MAX) {
1672
- return errorResponse(`At most ${PROPOSAL_BULK_APPLY_MAX} items per bulk apply.`, 400);
1673
- }
1674
- const items = rawItems.map((item) => ({
1675
- kind: item.kind,
1676
- id: item.id,
1677
- fingerprint: item.fingerprint,
1678
- }));
1679
- const result = await bulkApplySeoProposals(db(), {
1680
- items,
1681
- actor: { userId: auth.session.userId, role: auth.session.role },
1682
- authorizeContentWrite: contentWriteGate(auth.session),
1683
- });
1684
- if (result.blocked) {
1685
- return json({ error: result.reason, code: 'governance_blocked' }, 403);
1686
- }
1687
- return json({
1688
- data: {
1689
- results: result.results,
1690
- applied: result.applied,
1691
- failed: result.failed,
1692
- pending: result.pending,
1693
- },
1694
- });
1695
- }
1696
- catch (err) {
1697
- return internalError(err, 'seo/proposals/bulk-apply');
1698
- }
1699
- });
1700
- router.get('/seo/overview', async (request) => {
1701
- try {
1702
- const auth = await requireAuth(request);
1703
- if (auth.error)
1704
- return auth.error;
1705
- const { resolveSeoModuleSettings, getSeoOverrides } = await import('../../seo/config-store.js');
1706
- const { buildQuickWins } = await import('../../seo/audit-engine.js');
1707
- const { gradeForSeoScore } = await import('../../seo/score.js');
1708
- const overrides = await getSeoOverrides(db()).catch(() => null);
1709
- const moduleSettings = resolveSeoModuleSettings(overrides);
1710
- let lastRun = null;
1711
- let openIssues = [];
1712
- if (hasModel(db(), 'seoAuditRun')) {
1713
- lastRun = await db().seoAuditRun.findFirst({
1714
- where: { status: 'COMPLETED' },
1715
- orderBy: { startedAt: 'desc' },
1716
- });
1717
- }
1718
- if (hasModel(db(), 'seoIssue')) {
1719
- const rawIssues = await db().seoIssue.findMany({
1720
- where: { status: { in: ['open', 'recurring'] } },
1721
- orderBy: { lastDetectedAt: 'desc' },
1722
- take: 200,
1723
- });
1724
- openIssues = sortIssuesBySeverity(rawIssues);
1725
- }
1726
- const { frontEndContentCollectionTypes, countAuditEntities } = await import('../../seo/audit-runner.js');
1727
- const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
1728
- const total = await countAuditEntities(db(), { collectionTypes }).catch(() => 0);
1729
- const meta = (lastRun?.metadata ?? {});
1730
- const indexable = typeof meta.indexableCount === 'number' && Number.isFinite(meta.indexableCount)
1731
- ? meta.indexableCount
1732
- : total;
1733
- const auditTruncated = meta.truncated === true;
1734
- const perf = await loadSearchPerformance(10, moduleSettings.searchConsoleConnected);
1735
- const searchStats = moduleSettings.searchConsoleConnected
1736
- ? await loadSearchInsightStats()
1737
- : null;
1738
- const summary = {
1739
- total: openIssues.length,
1740
- critical: openIssues.filter((i) => i.severity === 'critical').length,
1741
- warning: openIssues.filter((i) => i.severity === 'warning').length,
1742
- info: openIssues.filter((i) => i.severity === 'info').length,
1743
- };
1744
- const { countPendingProposals } = await import('../../seo/proposals.js');
1745
- const pendingProposals = await countPendingProposals(db(), openIssues).catch(() => ({
1746
- issueFixes: 0,
1747
- redirects: 0,
1748
- }));
1749
- const scoreAfter = lastRun?.siteScoreAfter ?? null;
1750
- const scoreBefore = lastRun?.siteScoreBefore ?? null;
1751
- const breakdown = lastRun?.metadata?.breakdown ?? [];
1752
- // Real site-level stats (weighted by impressions, with prior-window
1753
- // trends) when synced data exists; the perf-row estimate otherwise.
1754
- const avgPosition = searchStats?.avgPosition ??
1755
- (perf.length > 0
1756
- ? perf.reduce((s, r) => s + r.averagePosition, 0) / perf.length
1757
- : null);
1758
- const impressions28d = searchStats?.impressions28d ?? perf.reduce((s, r) => s + r.impressions, 0);
1759
- // Position: lower is better, so an "up" trend means the number dropped.
1760
- let positionTrend = 'flat';
1761
- if (searchStats?.avgPosition != null && searchStats.avgPositionPrev != null) {
1762
- const delta = searchStats.avgPosition - searchStats.avgPositionPrev;
1763
- if (delta <= -0.5)
1764
- positionTrend = 'up';
1765
- else if (delta >= 0.5)
1766
- positionTrend = 'down';
1767
- }
1768
- let impressionsTrend = 'flat';
1769
- if (searchStats && searchStats.impressionsPrev28d > 0) {
1770
- const ratio = searchStats.impressions28d / searchStats.impressionsPrev28d;
1771
- if (ratio >= 1.05)
1772
- impressionsTrend = 'up';
1773
- else if (ratio <= 0.95)
1774
- impressionsTrend = 'down';
1775
- }
1776
- const metrics = [
1777
- {
1778
- label: 'Pages Indexed',
1779
- value: String(indexable),
1780
- helper: auditTruncated ? `${total} published (audit capped)` : `${total} published pages`,
1781
- trend: 'flat',
1782
- },
1783
- {
1784
- label: 'Open Issues',
1785
- value: String(summary.total),
1786
- helper: `${summary.critical} critical`,
1787
- trend: summary.critical > 0 ? 'down' : 'flat',
1788
- },
1789
- {
1790
- label: 'Avg. Position',
1791
- value: avgPosition !== null ? avgPosition.toFixed(1) : '—',
1792
- helper: moduleSettings.searchConsoleConnected
1793
- ? searchStats?.avgPositionPrev != null
1794
- ? `was ${searchStats.avgPositionPrev.toFixed(1)} the prior 28 days`
1795
- : 'last 28 days'
1796
- : 'Connect Search Console',
1797
- trend: positionTrend,
1798
- },
1799
- {
1800
- label: 'Impressions',
1801
- value: impressions28d > 0 ? formatCompactNumber(impressions28d) : '—',
1802
- helper: moduleSettings.searchConsoleConnected
1803
- ? searchStats && searchStats.impressionsPrev28d > 0
1804
- ? `was ${formatCompactNumber(searchStats.impressionsPrev28d)} the prior 28 days`
1805
- : 'last 28 days'
1806
- : 'Connect Search Console',
1807
- trend: impressionsTrend,
1808
- },
1809
- ];
1810
- // Weight quick wins by real traffic when synced search data exists.
1811
- const impressionsByEntityId = {};
1812
- if (moduleSettings.searchConsoleConnected && hasModel(db(), 'searchPageInsight')) {
1813
- try {
1814
- const impressionRows = (await db().searchPageInsight.findMany({
1815
- where: { entityId: { not: null }, impressions28d: { gt: 0 } },
1816
- select: { entityId: true, impressions28d: true },
1817
- take: 2000,
1818
- }));
1819
- for (const r of impressionRows) {
1820
- if (r.entityId)
1821
- impressionsByEntityId[r.entityId] = r.impressions28d;
1822
- }
1823
- }
1824
- catch {
1825
- /* weighting is optional */
1826
- }
1827
- }
1828
- const quickWins = buildQuickWins(openIssues.map((i) => ({
1829
- issueKey: i.issueKey,
1830
- entityType: i.entityType,
1831
- entityId: i.entityId,
1832
- entityTitle: i.entityTitle,
1833
- url: i.url,
1834
- severity: i.severity,
1835
- category: i.category,
1836
- type: i.type,
1837
- title: i.title,
1838
- description: i.description ?? '',
1839
- impact: i.impact ?? 'Low',
1840
- recommendation: i.recommendation ?? '',
1841
- fixable: i.fixable ?? false,
1842
- fixActionType: i.fixActionType ?? null,
1843
- relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
1844
- })), { impressionsByEntityId }).map((w) => ({
1845
- id: w.id,
1846
- title: w.title,
1847
- explanation: w.explanation,
1848
- impact: w.impact,
1849
- issueId: openIssues.find((i) => i.type === w.issueType)?.id ?? null,
1850
- fixActionType: w.fixActionType,
1851
- }));
1852
- return json({
1853
- data: {
1854
- siteScore: {
1855
- score: scoreAfter ?? 0,
1856
- grade: gradeForSeoScore(scoreAfter ?? 0),
1857
- delta: scoreAfter !== null && scoreBefore !== null ? scoreAfter - scoreBefore : null,
1858
- breakdown,
1859
- },
1860
- metrics,
1861
- issues: openIssues.slice(0, 7).map(seoIssueToApi),
1862
- issuesSummary: summary,
1863
- quickWins,
1864
- searchPerformance: perf,
1865
- searchConsoleConnected: moduleSettings.searchConsoleConnected,
1866
- lastAuditRunId: lastRun?.id ?? null,
1867
- auditTruncated,
1868
- pendingProposals,
1869
- },
1870
- });
1871
- }
1872
- catch (err) {
1873
- return internalError(err, 'seo/overview');
1874
- }
1875
- });
1876
- router.get('/seo/search-performance', async (request) => {
1877
- try {
1878
- const auth = await requireAuth(request);
1879
- if (auth.error)
1880
- return auth.error;
1881
- const url = new URL(request.url);
1882
- const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
1883
- const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
1884
- const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
1885
- const rows = await loadSearchPerformance(limit, settings.searchConsoleConnected);
1886
- return json({ data: { rows } });
1887
- }
1888
- catch (err) {
1889
- return internalError(err, 'seo/search-performance');
1890
- }
1891
- });
1892
- // Per-document search insight (synced GSC + CrUX) for the editor pane's
1893
- // read-only stats block. `insight: null` when not synced or no data.
1894
- router.get('/seo/search-insights/:entityId', async (request, params) => {
1895
- try {
1896
- const auth = await requireAuth(request);
1897
- if (auth.error)
1898
- return auth.error;
1899
- const entityId = params.entityId;
1900
- if (!entityId)
1901
- return errorResponse('entityId is required', 400);
1902
- const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
1903
- const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
1904
- const connected = settings.searchConsoleConnected;
1905
- if (!connected || !hasModel(db(), 'searchPageInsight')) {
1906
- return json({ data: { connected, insight: null } });
1907
- }
1908
- const row = await db().searchPageInsight.findFirst({
1909
- where: { entityId },
1910
- select: {
1911
- path: true,
1912
- clicks28d: true,
1913
- impressions28d: true,
1914
- avgPosition28d: true,
1915
- clicksPrev28d: true,
1916
- impressionsPrev28d: true,
1917
- avgPositionPrev28d: true,
1918
- topQueries: true,
1919
- lcpMs: true,
1920
- inpMs: true,
1921
- cls: true,
1922
- cwvStatus: true,
1923
- lastSyncedAt: true,
1924
- },
1925
- });
1926
- return json({
1927
- data: {
1928
- connected,
1929
- insight: row
1930
- ? {
1931
- ...row,
1932
- topQueries: Array.isArray(row.topQueries) ? row.topQueries : [],
1933
- lastSyncedAt: row.lastSyncedAt instanceof Date
1934
- ? row.lastSyncedAt.toISOString()
1935
- : row.lastSyncedAt,
1936
- }
1937
- : null,
1938
- },
1939
- });
1940
- }
1941
- catch (err) {
1942
- return internalError(err, 'seo/search-insights');
1943
- }
1944
- });
1945
- router.get('/seo/content', async (request) => {
1946
- try {
1947
- const auth = await requireAuth(request);
1948
- if (auth.error)
1949
- return auth.error;
1950
- // Scope the list to the collections this caller may read — otherwise a
1951
- // collection-scoped API key (or a collection-restricted role) could pull
1952
- // metaTitle/description/canonical/OG for every collection, the exact
1953
- // bypass the sibling document-level SEO endpoints already prevent.
1954
- const allowed = await readableSeoCollections(auth.session);
1955
- const url = new URL(request.url);
1956
- const page = clampPage(url.searchParams.get('page'));
1957
- const pageSize = clampPageSize(url.searchParams.get('pageSize'), 100, 50);
1958
- const result = await buildSeoContentRecords(allowed, undefined, {
1959
- skip: (page - 1) * pageSize,
1960
- take: pageSize,
1961
- });
1962
- return json({
1963
- data: {
1964
- records: result.records,
1965
- total: result.total,
1966
- page,
1967
- pageSize,
1968
- truncated: result.truncated,
1969
- },
1970
- });
1971
- }
1972
- catch (err) {
1973
- return internalError(err, 'seo/content');
1974
- }
1975
- });
1976
- router.get('/seo/content/:entityType/:entityId', async (request, params) => {
1977
- try {
1978
- const auth = await requireAuth(request);
1979
- if (auth.error)
1980
- return auth.error;
1981
- // Load the target first so we can enforce the collection's read-access
1982
- // rule (requireCollectionScope + assertCollectionAccess) before returning
1983
- // any SEO fields — parity with /seo/analysis and friends.
1984
- const doc = await db().document.findFirst({
1985
- where: { id: params.entityId, deletedAt: null },
1986
- select: { data: true, collection: true },
1987
- });
1988
- if (!doc)
1989
- return errorResponse('Content record not found', 404);
1990
- const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
1991
- if (accessErr)
1992
- return accessErr;
1993
- // Scoped to THIS document and not filtered by publication status: a saved
1994
- // draft has SEO worth editing, and the matching PUT accepts one. Resolving
1995
- // through the published-only gather made drafts 404 here while remaining
1996
- // writable — so an editor could save SEO and never read it back.
1997
- const { records } = await buildSeoContentRecords(null, [params.entityId]);
1998
- const record = records.find((r) => r.entityType === params.entityType && r.entityId === params.entityId);
1999
- if (!record)
2000
- return errorResponse('Content record not found', 404);
2001
- // Overlay the real llms.txt curation values from the stored document
2002
- // (the audit-entity pipeline that builds the base record doesn't carry
2003
- // these convention keys).
2004
- const data = asRecord(doc.data);
2005
- const includeRaw = String(data.llmsInclude ?? '').toUpperCase();
2006
- const merged = {
2007
- ...record,
2008
- llmsInclude: includeRaw === 'ALWAYS' || includeRaw === 'NEVER'
2009
- ? includeRaw
2010
- : 'AUTO',
2011
- llmsDescription: typeof data.llmsDescription === 'string' ? data.llmsDescription : null,
2012
- llmsSection: typeof data.llmsSection === 'string' ? data.llmsSection : null,
2013
- llmsOptional: data.llmsOptional === true,
2014
- // Robots directives live in the same content JSON. Overlaid here for
2015
- // the same reason as the llms.txt keys: the audit-entity pipeline that
2016
- // builds the base record does not carry them, so without this the
2017
- // editor would read every page back as fully permissive and silently
2018
- // overwrite a real restriction on the next save.
2019
- nosnippet: data.nosnippet === true,
2020
- noarchive: data.noarchive === true,
2021
- maxImagePreview: coerceMaxImagePreview(data.maxImagePreview),
2022
- };
2023
- return json({ data: merged });
2024
- }
2025
- catch (err) {
2026
- return internalError(err, 'seo/content/:id');
2027
- }
2028
- });
2029
- router.put('/seo/content/:entityType/:entityId', async (request, params) => {
2030
- try {
2031
- const auth = await requireAuth(request);
2032
- if (auth.error)
2033
- return auth.error;
2034
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2035
- if (roleErr)
2036
- return roleErr;
2037
- const body = (await request.json().catch(() => null));
2038
- if (!body || typeof body !== 'object') {
2039
- return errorResponse('Request body must be an object', 400);
2040
- }
2041
- const doc = await db().document.findFirst({
2042
- where: { id: params.entityId, deletedAt: null },
2043
- select: { id: true, collection: true },
2044
- });
2045
- if (!doc)
2046
- return errorResponse('Document not found', 404);
2047
- // String SEO fields: payload key -> stored data key, each with a max
2048
- // length so we never persist unbounded/megabyte values into the content
2049
- // JSON. Canonical is stored as `canonical` to match normalizeSeoPage.
2050
- const TEXT_FIELDS = {
2051
- metaTitle: { target: 'metaTitle', max: 300 },
2052
- metaDescription: { target: 'metaDescription', max: 1000 },
2053
- canonicalUrl: { target: 'canonical', max: 2000 },
2054
- focusKeyword: { target: 'focusKeyword', max: 200 },
2055
- keyTakeaway: { target: 'keyTakeaway', max: 500 },
2056
- ogTitle: { target: 'ogTitle', max: 300 },
2057
- ogDescription: { target: 'ogDescription', max: 1000 },
2058
- ogImage: { target: 'ogImage', max: 2000 },
2059
- twitterTitle: { target: 'twitterTitle', max: 300 },
2060
- twitterDescription: { target: 'twitterDescription', max: 1000 },
2061
- twitterImage: { target: 'twitterImage', max: 2000 },
2062
- structuredDataType: { target: 'structuredDataType', max: 100 },
2063
- schemaType: { target: 'schemaType', max: 100 },
2064
- llmsDescription: { target: 'llmsDescription', max: 500 },
2065
- llmsSection: { target: 'llmsSection', max: 100 },
2066
- };
2067
- const patch = {};
2068
- for (const [key, { target, max }] of Object.entries(TEXT_FIELDS)) {
2069
- if (!(key in body))
2070
- continue;
2071
- const value = body[key];
2072
- if (value === null || value === '') {
2073
- patch[target] = '';
2074
- continue;
2075
- }
2076
- if (typeof value !== 'string') {
2077
- return errorResponse(`Field "${key}" must be a string`, 400);
2078
- }
2079
- if (value.length > max) {
2080
- return errorResponse(`Field "${key}" exceeds the ${max}-character limit`, 400);
2081
- }
2082
- patch[target] = value;
2083
- }
2084
- if ('noindex' in body)
2085
- patch.noindex = body.noindex === true;
2086
- if ('nofollow' in body)
2087
- patch.nofollow = body.nofollow === true;
2088
- if ('nosnippet' in body)
2089
- patch.nosnippet = body.nosnippet === true;
2090
- if ('noarchive' in body)
2091
- patch.noarchive = body.noarchive === true;
2092
- if ('maxImagePreview' in body) {
2093
- // Coerced rather than rejected: the field is an enum with a permissive
2094
- // default, and refusing the write would block an otherwise valid patch
2095
- // over a value the composer already knows how to fall back from.
2096
- patch.maxImagePreview = coerceMaxImagePreview(body.maxImagePreview);
2097
- }
2098
- if ('llmsOptional' in body)
2099
- patch.llmsOptional = body.llmsOptional === true;
2100
- if ('llmsInclude' in body) {
2101
- const v = String(body.llmsInclude ?? '').toUpperCase();
2102
- if (v !== 'AUTO' && v !== 'ALWAYS' && v !== 'NEVER') {
2103
- return errorResponse('llmsInclude must be one of AUTO, ALWAYS, NEVER', 400);
2104
- }
2105
- // AUTO is the default — store empty so the field falls back cleanly.
2106
- patch.llmsInclude = v === 'AUTO' ? '' : v;
2107
- }
2108
- if ('llmsPriority' in body) {
2109
- const n = body.llmsPriority;
2110
- if (n !== null && typeof n !== 'number') {
2111
- return errorResponse('llmsPriority must be a number', 400);
2112
- }
2113
- patch.llmsPriority = n ?? 0;
2114
- }
2115
- if ('structuredData' in body) {
2116
- const sd = body.structuredData;
2117
- if (sd !== null && (typeof sd !== 'object' || Array.isArray(sd))) {
2118
- return errorResponse('structuredData must be a JSON object', 400);
2119
- }
2120
- if (sd && JSON.stringify(sd).length > 50_000) {
2121
- return errorResponse('structuredData payload is too large', 400);
2122
- }
2123
- patch.structuredData = sd;
2124
- }
2125
- if (Object.keys(patch).length === 0) {
2126
- return errorResponse('No recognized SEO fields in request body', 400);
2127
- }
2128
- if ('structuredDataType' in patch || 'schemaType' in patch) {
2129
- Object.assign(patch, syncSchemaTypeFields(patch));
2130
- }
2131
- // Route through the data layer so the edit gets per-collection access
2132
- // control, a Version snapshot, search re-indexing and the afterUpdate
2133
- // hook — never write document.data directly. (updateDocumentSeoFields
2134
- // preserves the undeclared SEO convention keys that the generic
2135
- // updateDocument field-access layer would strip.)
2136
- const ctx = buildActionContext(auth.session, db());
2137
- try {
2138
- await updateDocumentSeoFields(doc.collection, doc.id, patch, ctx);
2139
- }
2140
- catch (err) {
2141
- const msg = err instanceof Error ? err.message : '';
2142
- if (msg.includes('Access denied'))
2143
- return errorResponse('Insufficient permissions', 403);
2144
- if (msg.includes('not found'))
2145
- return errorResponse('Document not found', 404);
2146
- throw err;
2147
- }
2148
- try {
2149
- await logEvent({
2150
- event: 'document_updated',
2151
- userId: auth.session.userId,
2152
- details: { action: 'seo_fields_update', documentId: doc.id },
2153
- });
2154
- }
2155
- catch {
2156
- /* noop */
2157
- }
2158
- const { records } = await buildSeoContentRecords(null, [doc.id]);
2159
- const record = records.find((r) => r.entityId === doc.id);
2160
- return json({ data: record ?? { ok: true } });
2161
- }
2162
- catch (err) {
2163
- return internalError(err, 'seo/content PUT');
2164
- }
2165
- });
2166
- // ---------------------------------------------------------------------------
2167
- // SEO analysis, readability, internal links, schema, meta
2168
- // ---------------------------------------------------------------------------
2169
- router.get('/seo/analysis/:documentId', async (request, params) => {
2170
- try {
2171
- const auth = await requireAuth(request);
2172
- if (auth.error)
2173
- return auth.error;
2174
- const doc = await db().document.findFirst({
2175
- where: { id: params.documentId, deletedAt: null },
2176
- });
2177
- if (!doc)
2178
- return errorResponse('Not found', 404);
2179
- const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
2180
- if (accessErr)
2181
- return accessErr;
2182
- const { analyzeContent } = await import('../../seo/analysis.js');
2183
- const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
2184
- const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
2185
- const data = doc.data || {};
2186
- // Same content assembly as readability + audit entity (`documentContentForSeoAnalysis`)
2187
- // so MCP/admin scores agree and section-built pages still get plainText/sections.
2188
- const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
2189
- const overrides = await getSeoOverrides(db()).catch(() => null);
2190
- const siteSeo = applySeoOverrides(getPrentaConfig(), overrides)?.seo;
2191
- const entity = documentToAuditEntity(doc, collectionTypes, siteSeo);
2192
- const result = analyzeContent({
2193
- title: entity.title,
2194
- slug: doc.slug || data.slug || '',
2195
- content: entity.content ?? '',
2196
- // Rendered title (site template applied) — the same value the page
2197
- // score measures, so both agree instead of one reading the page portion.
2198
- metaTitle: entity.metaTitle ?? undefined,
2199
- metaDescription: entity.metaDescription ?? undefined,
2200
- focusKeyphrase: entity.focusKeyword ?? undefined,
2201
- canonical: entity.canonicalUrl ?? undefined,
2202
- ogTitle: entity.ogTitle ?? undefined,
2203
- ogDescription: entity.ogDescription ?? undefined,
2204
- ogImage: entity.ogImage ?? undefined,
2205
- extraImages: extraImagesForSeoAnalysis(data),
2206
- entityType: entity.entityType,
2207
- sectionH1: firstSectionHeading(data) ?? undefined,
2208
- isCornerstone: data.isCornerstone,
2209
- });
2210
- return new Response(JSON.stringify(result), {
2211
- status: 200,
2212
- headers: { 'Content-Type': 'application/json' },
2213
- });
2214
- }
2215
- catch (err) {
2216
- return internalError(err, 'seo/analysis');
2217
- }
2218
- });
2219
- router.get('/seo/readability/:documentId', async (request, params) => {
2220
- try {
2221
- const auth = await requireAuth(request);
2222
- if (auth.error)
2223
- return auth.error;
2224
- const doc = await db().document.findFirst({
2225
- where: { id: params.documentId, deletedAt: null },
2226
- });
2227
- if (!doc)
2228
- return errorResponse('Not found', 404);
2229
- const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
2230
- if (accessErr)
2231
- return accessErr;
2232
- const { calculateReadability, stripHtmlTags } = await import('../../seo/analysis.js');
2233
- const { documentContentForSeoAnalysis } = await import('../../seo/document-content.js');
2234
- const text = stripHtmlTags(documentContentForSeoAnalysis(doc));
2235
- const result = calculateReadability(text);
2236
- return new Response(JSON.stringify(result), {
2237
- status: 200,
2238
- headers: { 'Content-Type': 'application/json' },
2239
- });
2240
- }
2241
- catch (err) {
2242
- return internalError(err, 'seo/readability');
2243
- }
2244
- });
2245
- router.get('/seo/internal-links/:documentId', async (request, params) => {
2246
- try {
2247
- const auth = await requireAuth(request);
2248
- if (auth.error)
2249
- return auth.error;
2250
- const doc = await db().document.findFirst({
2251
- where: { id: params.documentId, deletedAt: null },
2252
- });
2253
- if (!doc)
2254
- return errorResponse('Not found', 404);
2255
- const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
2256
- if (accessErr)
2257
- return accessErr;
2258
- const data = doc.data || {};
2259
- const title = doc.title || data.title || '';
2260
- const keywords = title
2261
- .split(/\s+/)
2262
- .filter((w) => w.length > 3)
2263
- .slice(0, 5);
2264
- if (keywords.length === 0) {
2265
- return new Response(JSON.stringify({ suggestions: [] }), {
2266
- status: 200,
2267
- headers: { 'Content-Type': 'application/json' },
2268
- });
2269
- }
2270
- const suggestions = await db().document.findMany({
2271
- where: {
2272
- id: { not: params.documentId },
2273
- status: 'PUBLISHED',
2274
- deletedAt: null,
2275
- OR: keywords.map((kw) => ({
2276
- title: { contains: kw, mode: 'insensitive' },
2277
- })),
2278
- },
2279
- select: { id: true, title: true, slug: true, collection: true },
2280
- take: 10,
2281
- orderBy: { updatedAt: 'desc' },
2282
- });
2283
- return new Response(JSON.stringify({ suggestions }), {
2284
- status: 200,
2285
- headers: { 'Content-Type': 'application/json' },
2286
- });
2287
- }
2288
- catch (err) {
2289
- return internalError(err, 'seo/internal-links');
2290
- }
2291
- });
2292
- router.get('/llms.txt', async (request) => {
2293
- try {
2294
- // Public surface: must exclude soft-deleted docs and internal/system
2295
- // collections (e.g. `__seo_config__`). Over-fetch then filter so the
2296
- // 50-page cap still lands on real content after exclusion.
2297
- const { isInternalCollection } = await import('../../seo/audit-runner.js');
2298
- const { siteUrlFromRequest, loadEffectiveConfig } = await import('./seo-public.js');
2299
- // Layer DB overrides (Settings → SEO) so an admin-edited siteName /
2300
- // defaultMetaDescription flows into the llms heading + summary too, exactly
2301
- // as sitemap.xml, robots.txt, /og.png and the feeds already do.
2302
- const cfg = (await loadEffectiveConfig()) ?? getPrentaConfig();
2303
- const llmsCfg = cfg?.seo?.llms;
2304
- const base = siteUrlFromRequest(request, cfg);
2305
- // Operators can disable the file entirely (it's optionality, not a
2306
- // guaranteed traffic channel).
2307
- if (llmsCfg?.disabled) {
2308
- return new Response('Not found', { status: 404 });
2309
- }
2310
- const maxEntries = llmsCfg?.maxEntries ?? 50;
2311
- const maxPerSection = llmsCfg?.maxPerSection ?? 25;
2312
- // Restrict AUTO selection to the configured high-value collections when
2313
- // set, so blog archives don't flood the curated guide.
2314
- const eligible = Array.isArray(llmsCfg?.eligibleCollections) && llmsCfg.eligibleCollections.length > 0
2315
- ? new Set(llmsCfg.eligibleCollections)
2316
- : null;
2317
- const rawDocs = await db().document.findMany({
2318
- where: { status: 'PUBLISHED', deletedAt: null },
2319
- select: { title: true, slug: true, collection: true, updatedAt: true, data: true },
2320
- orderBy: { updatedAt: 'desc' },
2321
- take: 500,
2322
- });
2323
- // Per-doc curation + privacy boundary (noindex exclusion) live in the
2324
- // shared selector so `/llms.txt` and `/llms-full.txt` can never diverge.
2325
- const { selectLlmsDocs, toLlmsPage } = await import('../../seo/llms-select.js');
2326
- const selected = selectLlmsDocs(rawDocs, { isInternalCollection, eligible });
2327
- const { generateLlmsTxt } = await import('../../seo/llms-txt.js');
2328
- const pages = selected.map((d) => toLlmsPage(d, {
2329
- base,
2330
- urlPrefix: cfg?.collections?.[d.collection]?.urlPrefix,
2331
- trailingSlash: cfg?.seo?.trailingSlash,
2332
- }));
2333
- const txt = generateLlmsTxt({
2334
- siteName: cfg?.seo?.siteName || 'Prenta CMS',
2335
- siteUrl: base,
2336
- siteDescription: llmsCfg?.summary || cfg?.seo?.defaultMetaDescription || 'Content managed by Prenta CMS',
2337
- intro: llmsCfg?.intro,
2338
- maxPages: maxEntries,
2339
- maxPerSection,
2340
- }, pages);
2341
- // Opt-in honeypot: append a unique trap link. No human reads llms.txt, so
2342
- // a fetch of this URL is an automated reader — recorded under the
2343
- // "honeypot" surface in the AI-crawler panel.
2344
- let body = txt;
2345
- if (llmsCfg?.honeypot) {
2346
- const trapUrl = `${base.replace(/\/$/, '')}/api/cms/ai-crawlers/trap`;
2347
- body += `\n## Reference\n\n- [Complete content index](${trapUrl}): Full machine-readable content map.\n`;
2348
- }
2349
- // Measurement: if a known AI crawler fetched the index, count it.
2350
- await recordAiCrawlerFromRequest(db(), request, 'llms');
2351
- return new Response(body, {
2352
- status: 200,
2353
- headers: {
2354
- 'Content-Type': 'text/plain; charset=utf-8',
2355
- // The file itself is a machine-facing index, not a page to rank.
2356
- 'X-Robots-Tag': 'noindex',
2357
- // Same CDN TTL as robots.txt — the index is cheap to regenerate
2358
- // after publish but should not be fetched on every agent request.
2359
- 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
2360
- },
2361
- });
2362
- }
2363
- catch (err) {
2364
- return internalError(err, 'llms.txt');
2365
- }
2366
- });
2367
- // `/llms-full.txt` — same curated selection as `/llms.txt`, but inlines each
2368
- // page's content rendered to clean Markdown so an agent can read the actual
2369
- // body without fetching every URL. Opt-in (these files get large) and bounded
2370
- // by a byte budget. noindex + never in the sitemap; HTML stays canonical.
2371
- router.get('/llms-full.txt', async (request) => {
2372
- try {
2373
- const { isInternalCollection } = await import('../../seo/audit-runner.js');
2374
- const { siteUrlFromRequest, loadEffectiveConfig } = await import('./seo-public.js');
2375
- // Honor admin overrides (siteName / summary / defaultMetaDescription) the
2376
- // same way `/llms.txt` and the other public SEO surfaces do.
2377
- const cfg = (await loadEffectiveConfig()) ?? getPrentaConfig();
2378
- const llmsCfg = cfg?.seo?.llms;
2379
- const base = siteUrlFromRequest(request, cfg);
2380
- // Gated independently from `/llms.txt`: must be both enabled overall and
2381
- // have the full variant turned on.
2382
- if (llmsCfg?.disabled || !llmsCfg?.fullEnabled) {
2383
- return new Response('Not found', { status: 404 });
2384
- }
2385
- const maxEntries = llmsCfg?.maxEntries ?? 50;
2386
- const maxBytes = llmsCfg?.maxFullBytes ?? 2_000_000;
2387
- const eligible = Array.isArray(llmsCfg?.eligibleCollections) && llmsCfg.eligibleCollections.length > 0
2388
- ? new Set(llmsCfg.eligibleCollections)
2389
- : null;
2390
- const rawDocs = await db().document.findMany({
2391
- where: { status: 'PUBLISHED', deletedAt: null },
2392
- select: {
2393
- title: true,
2394
- slug: true,
2395
- collection: true,
2396
- updatedAt: true,
2397
- data: true,
2398
- plainText: true,
2399
- },
2400
- orderBy: { updatedAt: 'desc' },
2401
- take: 500,
2402
- });
2403
- const { selectLlmsDocs, toLlmsPage } = await import('../../seo/llms-select.js');
2404
- const { documentToMarkdown } = await import('../../seo/content-markdown.js');
2405
- const selected = selectLlmsDocs(rawDocs, { isInternalCollection, eligible }).slice(0, maxEntries);
2406
- const header = [
2407
- `# ${cfg?.seo?.siteName || 'Prenta CMS'}`,
2408
- '',
2409
- `> ${llmsCfg?.summary || cfg?.seo?.defaultMetaDescription || 'Full content export for AI agents.'}`,
2410
- '',
2411
- ].join('\n');
2412
- const chunks = [header];
2413
- let bytes = Buffer.byteLength(header, 'utf8');
2414
- let truncated = false;
2415
- for (const d of selected) {
2416
- const page = toLlmsPage(d, {
2417
- base,
2418
- urlPrefix: cfg?.collections?.[d.collection]?.urlPrefix,
2419
- trailingSlash: cfg?.seo?.trailingSlash,
2420
- });
2421
- const md = documentToMarkdown({
2422
- title: d.title,
2423
- data: d.data,
2424
- plainText: d.plainText,
2425
- }, { baseUrl: base });
2426
- // Each entry is delimited by a horizontal rule and carries its canonical
2427
- // URL so the inlined content stays traceable back to the live page.
2428
- const block = `\n---\n\nURL: ${page.url}\n\n${md}\n`;
2429
- const blockBytes = Buffer.byteLength(block, 'utf8');
2430
- if (bytes + blockBytes > maxBytes) {
2431
- truncated = true;
2432
- break;
2433
- }
2434
- chunks.push(block);
2435
- bytes += blockBytes;
2436
- }
2437
- if (truncated) {
2438
- chunks.push('\n---\n\n> Output truncated at the configured size limit.\n');
2439
- }
2440
- await recordAiCrawlerFromRequest(db(), request, 'llms-full');
2441
- return new Response(chunks.join(''), {
2442
- status: 200,
2443
- headers: {
2444
- 'Content-Type': 'text/plain; charset=utf-8',
2445
- 'X-Robots-Tag': 'noindex',
2446
- 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
2447
- },
2448
- });
2449
- }
2450
- catch (err) {
2451
- return internalError(err, 'llms-full.txt');
2452
- }
2453
- });
2454
- // Per-page Markdown: `GET /md?path=/about` returns a clean Markdown rendering
2455
- // of a single published page so an agent can fetch the content, not just the
2456
- // link. Fresh installs expose this at clean `/{path}.md` URLs via a scaffolded
2457
- // rewrite. CRITICAL: always noindex, never in the sitemap — the HTML page
2458
- // stays canonical (per-page `.md` is the most common duplicate-content trap).
2459
- router.get('/md', async (request) => {
2460
- try {
2461
- const cfg = getPrentaConfig();
2462
- const url = new URL(request.url);
2463
- const pathParam = url.searchParams.get('path');
2464
- if (!pathParam) {
2465
- return errorResponse('Missing required "path" query parameter', 400);
2466
- }
2467
- // Part of the AI/agent layer — honor the same master switch as llms.txt.
2468
- if (cfg?.seo?.llms?.disabled) {
2469
- return new Response('Not found', { status: 404 });
2470
- }
2471
- const { matchPathToCollection } = await import('../../seo/resolve-path.js');
2472
- const match = matchPathToCollection(cfg, pathParam);
2473
- if (!match) {
2474
- return new Response('Not found', { status: 404 });
2475
- }
2476
- const slugFilter = match.isRoot
2477
- ? {
2478
- OR: [
2479
- { data: { path: ['slug'], equals: 'home' } },
2480
- { data: { path: ['slug'], equals: 'index' } },
2481
- { slug: 'home' },
2482
- { slug: 'index' },
2483
- ],
2484
- }
2485
- : {
2486
- OR: [{ data: { path: ['slug'], equals: match.slug } }, { slug: match.slug }],
2487
- };
2488
- const doc = await db().document.findFirst({
2489
- where: {
2490
- collection: match.collection,
2491
- deletedAt: null,
2492
- status: 'PUBLISHED',
2493
- ...slugFilter,
2494
- },
2495
- select: { title: true, data: true, plainText: true },
2496
- });
2497
- if (!doc) {
2498
- return new Response('Not found', { status: 404 });
2499
- }
2500
- const data = doc.data || {};
2501
- // The selection filter is the privacy boundary: noindexed or explicitly
2502
- // excluded pages must not be fetchable as Markdown either.
2503
- const { isLlmsNoindex, readLlmsInclude } = await import('../../seo/llms-select.js');
2504
- if (isLlmsNoindex(data) || readLlmsInclude(data) === 'NEVER') {
2505
- return new Response('Not found', { status: 404 });
2506
- }
2507
- const { documentToMarkdown } = await import('../../seo/content-markdown.js');
2508
- const { siteUrlFromRequest } = await import('./seo-public.js');
2509
- const base = siteUrlFromRequest(request, cfg);
2510
- const md = documentToMarkdown({ title: doc.title, data, plainText: doc.plainText }, { baseUrl: base });
2511
- await recordAiCrawlerFromRequest(db(), request, 'md');
2512
- return new Response(md, {
2513
- status: 200,
2514
- headers: {
2515
- 'Content-Type': 'text/markdown; charset=utf-8',
2516
- 'X-Robots-Tag': 'noindex',
2517
- },
2518
- });
2519
- }
2520
- catch (err) {
2521
- return internalError(err, 'md');
2522
- }
2523
- });
2524
- // Honeypot: a unique trap URL appended to llms.txt only when
2525
- // `seo.llms.honeypot` is enabled. No human visits it, so any fetch is an
2526
- // automated reader that followed a link from the file. Always records (even
2527
- // if the flag was later turned off, a cached llms.txt may still point here)
2528
- // and returns a tiny noindex body.
2529
- router.get('/ai-crawlers/trap', async (request) => {
2530
- try {
2531
- const detected = detectAiBot(request.headers.get('user-agent'));
2532
- await recordAiCrawlerHit(db(), {
2533
- bot: detected?.token ?? 'unknown',
2534
- surface: 'honeypot',
2535
- category: detected?.category ?? 'other',
2536
- });
2537
- return new Response('This endpoint exists to detect automated indexers.\n', {
2538
- status: 200,
2539
- headers: {
2540
- 'Content-Type': 'text/plain; charset=utf-8',
2541
- 'X-Robots-Tag': 'noindex',
2542
- },
2543
- });
2544
- }
2545
- catch (err) {
2546
- return internalError(err, 'ai-crawlers/trap');
2547
- }
2548
- });
2549
- // Admin: aggregated AI-crawler activity for the SEO → Technical panel.
2550
- // Any authenticated admin may read it (analytics, not a mutation).
2551
- router.get('/ai-crawlers', async (request) => {
2552
- try {
2553
- const auth = await requireAuth(request);
2554
- if (auth.error)
2555
- return auth.error;
2556
- if (!hasModel(db(), 'aiCrawlerHit')) {
2557
- return json({
2558
- data: { enabled: false, rows: [], totals: { hits: 0, bots: 0, honeypot: 0 } },
2559
- });
2560
- }
2561
- const rows = await db().aiCrawlerHit.findMany({
2562
- orderBy: { lastSeenAt: 'desc' },
2563
- take: 500,
2564
- });
2565
- const totalHits = rows.reduce((sum, r) => sum + Number(r.hits ?? 0), 0);
2566
- const honeypotHits = rows
2567
- .filter((r) => r.surface === 'honeypot')
2568
- .reduce((sum, r) => sum + Number(r.hits ?? 0), 0);
2569
- const uniqueBots = new Set(rows.map((r) => r.bot)).size;
2570
- // Recency windowing. `hits` is a lifetime cumulative counter (one row per
2571
- // bot×surface), so we can't derive per-window hit *counts* from it — but
2572
- // `lastSeenAt` lets us answer the actionable question "which bots/surfaces
2573
- // were active in the last 24h / 7d". Per-window hit-count trends would
2574
- // need a (bot, surface, day) bucket table (a future additive migration);
2575
- // this keeps storage bounded while making recency visible today.
2576
- const nowMs = Date.now();
2577
- const DAY_MS = 24 * 60 * 60 * 1000;
2578
- const within = (v, ms) => {
2579
- if (!v)
2580
- return false;
2581
- const t = v instanceof Date ? v.getTime() : new Date(v).getTime();
2582
- return Number.isFinite(t) && nowMs - t <= ms;
2583
- };
2584
- const windowSummary = (ms) => {
2585
- const active = rows.filter((r) => within(r.lastSeenAt, ms));
2586
- return {
2587
- bots: new Set(active.map((r) => r.bot)).size,
2588
- surfaces: new Set(active.map((r) => r.surface)).size,
2589
- rows: active.length,
2590
- };
2591
- };
2592
- return json({
2593
- data: {
2594
- enabled: true,
2595
- rows: rows.map((r) => ({
2596
- bot: r.bot,
2597
- surface: r.surface,
2598
- category: r.category,
2599
- hits: Number(r.hits ?? 0),
2600
- firstSeenAt: r.firstSeenAt,
2601
- lastSeenAt: r.lastSeenAt,
2602
- activeLast24h: within(r.lastSeenAt, DAY_MS),
2603
- activeLast7d: within(r.lastSeenAt, 7 * DAY_MS),
2604
- })),
2605
- totals: { hits: totalHits, bots: uniqueBots, honeypot: honeypotHits },
2606
- windows: { last24h: windowSummary(DAY_MS), last7d: windowSummary(7 * DAY_MS) },
2607
- },
2608
- });
2609
- }
2610
- catch (err) {
2611
- return internalError(err, 'ai-crawlers');
2612
- }
2613
- });
18
+ router.get('/seo/pages', getSeoPages);
19
+ router.get('/seo/link-health', getSeoLinkHealth);
20
+ router.get('/seo/links/snapshot', getSeoLinkSnapshot);
21
+ router.post('/seo/links/ai-index', postSeoLinkAiIndex);
22
+ router.post('/seo/links/suggest', postSeoLinkSuggest);
23
+ router.post('/seo/links/apply', postSeoLinkApply);
24
+ router.post('/seo/scan', postSeoScan);
25
+ router.post('/seo/audit', postSeoAudit);
26
+ router.get('/seo/audits', getSeoAudits);
27
+ router.get('/seo/audits/:id', getSeoAudit);
28
+ router.get('/seo/issues', getSeoIssues);
29
+ router.get('/seo/issues/export-brief', getSeoIssuesExportBrief);
30
+ router.post('/seo/issues/batch-content-briefs', postSeoIssuesBatchContentBriefs);
31
+ router.get('/seo/issues/:id/content-brief', getSeoIssueContentBrief);
32
+ router.post('/seo/issues/:id/content-brief', postSeoIssueContentBrief);
33
+ router.get('/seo/issues/:id/content-research', getSeoIssueContentResearch);
34
+ router.post('/seo/issues/:id/content-research', postSeoIssueContentResearch);
35
+ router.post('/seo/issues/:id/research-and-brief', postSeoIssueResearchAndBrief);
36
+ router.get('/seo/issues/:id', getSeoIssue);
37
+ router.post('/seo/issues/:id/resolve', postSeoIssueResolve);
38
+ router.post('/seo/issues/:id/ignore', postSeoIssueIgnore);
39
+ router.post('/seo/issues/:id/reopen', postSeoIssueReopen);
40
+ router.post('/seo/issues/:id/suggest-fix', postSeoIssueSuggestFix);
41
+ router.post('/seo/issues/:id/apply-fix', postSeoIssueApplyFix);
42
+ router.post('/seo/issues/:id/dismiss-fix', postSeoIssueDismissFix);
43
+ router.get('/seo/proposals', getSeoProposals);
44
+ router.post('/seo/proposals/generate', postSeoProposalsGenerate);
45
+ router.post('/seo/proposals/bulk-apply', postSeoProposalsBulkApply);
46
+ router.get('/seo/overview', getSeoOverview);
47
+ router.get('/seo/search-performance', getSeoSearchPerformance);
48
+ router.get('/seo/search-insights/:entityId', getSeoSearchInsights);
49
+ router.get('/seo/content', getSeoContent);
50
+ router.get('/seo/content/:entityType/:entityId', getSeoContentRecord);
51
+ router.put('/seo/content/:entityType/:entityId', putSeoContentRecord);
52
+ router.get('/seo/analysis/:documentId', getSeoAnalysis);
53
+ router.get('/seo/readability/:documentId', getSeoReadability);
54
+ router.get('/seo/internal-links/:documentId', getSeoInternalLinks);
55
+ router.get('/llms.txt', getLlmsTxt);
56
+ router.get('/llms-full.txt', getLlmsFullTxt);
57
+ router.get('/md', getMarkdownForPath);
58
+ router.get('/ai-crawlers/trap', getAiCrawlerTrap);
59
+ router.get('/ai-crawlers', getAiCrawlers);
2614
60
  }
2615
61
  //# sourceMappingURL=seo.js.map