@actuate-media/cms-core 0.28.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/media/seo.test.d.ts +2 -0
- package/dist/__tests__/media/seo.test.d.ts.map +1 -0
- package/dist/__tests__/media/seo.test.js +186 -0
- package/dist/__tests__/media/seo.test.js.map +1 -0
- package/dist/__tests__/redirects/graph.test.d.ts +2 -0
- package/dist/__tests__/redirects/graph.test.d.ts.map +1 -0
- package/dist/__tests__/redirects/graph.test.js +104 -0
- package/dist/__tests__/redirects/graph.test.js.map +1 -0
- package/dist/__tests__/redirects/suggest.test.d.ts +2 -0
- package/dist/__tests__/redirects/suggest.test.d.ts.map +1 -0
- package/dist/__tests__/redirects/suggest.test.js +28 -0
- package/dist/__tests__/redirects/suggest.test.js.map +1 -0
- package/dist/__tests__/seo/audit-engine.test.d.ts +2 -0
- package/dist/__tests__/seo/audit-engine.test.d.ts.map +1 -0
- package/dist/__tests__/seo/audit-engine.test.js +152 -0
- package/dist/__tests__/seo/audit-engine.test.js.map +1 -0
- package/dist/__tests__/seo/config-store.test.js +24 -160
- package/dist/__tests__/seo/config-store.test.js.map +1 -1
- package/dist/__tests__/seo/score.test.d.ts +2 -0
- package/dist/__tests__/seo/score.test.d.ts.map +1 -0
- package/dist/__tests__/seo/score.test.js +152 -0
- package/dist/__tests__/seo/score.test.js.map +1 -0
- package/dist/__tests__/setup/index.test.d.ts +2 -0
- package/dist/__tests__/setup/index.test.d.ts.map +1 -0
- package/dist/__tests__/setup/index.test.js +82 -0
- package/dist/__tests__/setup/index.test.js.map +1 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +2095 -228
- package/dist/api/handlers.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/media/index.d.ts +2 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +1 -0
- package/dist/media/index.js.map +1 -1
- package/dist/media/seo.d.ts +88 -0
- package/dist/media/seo.d.ts.map +1 -0
- package/dist/media/seo.js +215 -0
- package/dist/media/seo.js.map +1 -0
- package/dist/redirects/graph.d.ts +68 -0
- package/dist/redirects/graph.d.ts.map +1 -0
- package/dist/redirects/graph.js +136 -0
- package/dist/redirects/graph.js.map +1 -0
- package/dist/redirects/index.d.ts +5 -0
- package/dist/redirects/index.d.ts.map +1 -0
- package/dist/redirects/index.js +3 -0
- package/dist/redirects/index.js.map +1 -0
- package/dist/redirects/suggest.d.ts +21 -0
- package/dist/redirects/suggest.d.ts.map +1 -0
- package/dist/redirects/suggest.js +55 -0
- package/dist/redirects/suggest.js.map +1 -0
- package/dist/seo/audit-engine.d.ts +119 -0
- package/dist/seo/audit-engine.d.ts.map +1 -0
- package/dist/seo/audit-engine.js +402 -0
- package/dist/seo/audit-engine.js.map +1 -0
- package/dist/seo/audit-runner.d.ts +41 -0
- package/dist/seo/audit-runner.d.ts.map +1 -0
- package/dist/seo/audit-runner.js +223 -0
- package/dist/seo/audit-runner.js.map +1 -0
- package/dist/seo/config-store.d.ts +28 -0
- package/dist/seo/config-store.d.ts.map +1 -1
- package/dist/seo/config-store.js +22 -0
- package/dist/seo/config-store.js.map +1 -1
- package/dist/seo/index.d.ts +7 -2
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +4 -1
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/score.d.ts +128 -0
- package/dist/seo/score.d.ts.map +1 -0
- package/dist/seo/score.js +213 -0
- package/dist/seo/score.js.map +1 -0
- package/dist/setup/index.d.ts +16 -1
- package/dist/setup/index.d.ts.map +1 -1
- package/dist/setup/index.js +52 -2
- package/dist/setup/index.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/0008_media_center/migration.sql +47 -0
- package/prisma/migrations/0009_seo/migration.sql +143 -0
- package/prisma/schema.prisma +183 -8
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO scoring model.
|
|
3
|
+
*
|
|
4
|
+
* Pure, dependency-light functions that turn a page/post's metadata + content
|
|
5
|
+
* into an explainable 0-100 score, and aggregate per-category signals into a
|
|
6
|
+
* site-wide score. Nothing here is persisted directly — the audit engine
|
|
7
|
+
* (Phase 2) calls these and snapshots the results into `SeoAuditRun` /
|
|
8
|
+
* `SeoIssue` rows, while the Content SEO table recomputes page scores on read.
|
|
9
|
+
*
|
|
10
|
+
* The score is intentionally NOT a mystery number: every result carries a
|
|
11
|
+
* `checks` array describing exactly which points were earned/lost and why, so
|
|
12
|
+
* the admin UI can show editors what to fix. The unit tests in
|
|
13
|
+
* `__tests__/seo/score.test.ts` are the source of truth for the weights.
|
|
14
|
+
*/
|
|
15
|
+
import { stripHtmlTags } from './analysis.js';
|
|
16
|
+
/** Page score weights. Must sum to 100. */
|
|
17
|
+
export const PAGE_SEO_WEIGHTS = {
|
|
18
|
+
metaTitlePresent: 10,
|
|
19
|
+
metaTitleLength: 5,
|
|
20
|
+
metaDescPresent: 10,
|
|
21
|
+
metaDescLength: 5,
|
|
22
|
+
focusKeyword: 8,
|
|
23
|
+
canonical: 7,
|
|
24
|
+
h1: 10,
|
|
25
|
+
contentLength: 10,
|
|
26
|
+
ogImage: 8,
|
|
27
|
+
ogTitleDesc: 5,
|
|
28
|
+
structuredData: 7,
|
|
29
|
+
internalLinks: 5,
|
|
30
|
+
indexable: 10,
|
|
31
|
+
};
|
|
32
|
+
/** Site score category weights (spec). Must sum to 100. */
|
|
33
|
+
export const SITE_SEO_WEIGHTS = {
|
|
34
|
+
content: 30,
|
|
35
|
+
technical: 25,
|
|
36
|
+
linkRedirect: 20,
|
|
37
|
+
structuredSocial: 15,
|
|
38
|
+
performance: 10,
|
|
39
|
+
};
|
|
40
|
+
const MIN_CONTENT_WORDS = 300;
|
|
41
|
+
const IMPROVE_CONTENT_WORDS = 150;
|
|
42
|
+
function present(value) {
|
|
43
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
44
|
+
}
|
|
45
|
+
/** Map a 0-100 score to a grade. Aligns with the Content SEO table colours. */
|
|
46
|
+
export function gradeForSeoScore(score) {
|
|
47
|
+
if (score >= 80)
|
|
48
|
+
return 'good';
|
|
49
|
+
if (score >= 60)
|
|
50
|
+
return 'fair';
|
|
51
|
+
if (score >= 1)
|
|
52
|
+
return 'poor';
|
|
53
|
+
return 'critical';
|
|
54
|
+
}
|
|
55
|
+
/** Count `<h1>` tags in an HTML string (case-insensitive). */
|
|
56
|
+
export function countH1(html) {
|
|
57
|
+
const matches = html.match(/<h1[\s>]/gi);
|
|
58
|
+
return matches ? matches.length : 0;
|
|
59
|
+
}
|
|
60
|
+
function wordCountOf(content) {
|
|
61
|
+
const text = stripHtmlTags(content);
|
|
62
|
+
if (!text)
|
|
63
|
+
return 0;
|
|
64
|
+
return text.split(/\s+/).filter((w) => w.length > 0).length;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Compute a page/post SEO score (0-100) with an explainable breakdown.
|
|
68
|
+
*
|
|
69
|
+
* Points are additive per {@link PAGE_SEO_WEIGHTS}; "warn" checks earn partial
|
|
70
|
+
* credit. The total is clamped to 0-100 and rounded.
|
|
71
|
+
*/
|
|
72
|
+
export function calculatePageSeoScore(input) {
|
|
73
|
+
const checks = [];
|
|
74
|
+
const push = (id, label, max, status, message) => {
|
|
75
|
+
const points = status === 'pass' ? max : status === 'warn' ? Math.round(max * 0.5) : 0;
|
|
76
|
+
checks.push({ id, label, points, max, status, message });
|
|
77
|
+
};
|
|
78
|
+
// -- Meta title -----------------------------------------------------------
|
|
79
|
+
const metaTitle = (input.metaTitle ?? '').trim();
|
|
80
|
+
if (metaTitle.length === 0) {
|
|
81
|
+
push('meta-title', 'Meta title', PAGE_SEO_WEIGHTS.metaTitlePresent, 'fail', 'No meta title set.');
|
|
82
|
+
push('meta-title-length', 'Meta title length', PAGE_SEO_WEIGHTS.metaTitleLength, 'fail', 'Add a 30–60 character meta title.');
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
push('meta-title', 'Meta title', PAGE_SEO_WEIGHTS.metaTitlePresent, 'pass', 'Meta title is set.');
|
|
86
|
+
const len = metaTitle.length;
|
|
87
|
+
if (len >= 30 && len <= 60) {
|
|
88
|
+
push('meta-title-length', 'Meta title length', PAGE_SEO_WEIGHTS.metaTitleLength, 'pass', `Meta title is ${len} characters — ideal.`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
push('meta-title-length', 'Meta title length', PAGE_SEO_WEIGHTS.metaTitleLength, 'warn', `Meta title is ${len} characters. Aim for 30–60.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// -- Meta description -----------------------------------------------------
|
|
95
|
+
const metaDesc = (input.metaDescription ?? '').trim();
|
|
96
|
+
if (metaDesc.length === 0) {
|
|
97
|
+
push('meta-description', 'Meta description', PAGE_SEO_WEIGHTS.metaDescPresent, 'fail', 'No meta description set.');
|
|
98
|
+
push('meta-description-length', 'Meta description length', PAGE_SEO_WEIGHTS.metaDescLength, 'fail', 'Add a 120–160 character description.');
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
push('meta-description', 'Meta description', PAGE_SEO_WEIGHTS.metaDescPresent, 'pass', 'Meta description is set.');
|
|
102
|
+
const len = metaDesc.length;
|
|
103
|
+
if (len >= 120 && len <= 160) {
|
|
104
|
+
push('meta-description-length', 'Meta description length', PAGE_SEO_WEIGHTS.metaDescLength, 'pass', `Meta description is ${len} characters — ideal.`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
push('meta-description-length', 'Meta description length', PAGE_SEO_WEIGHTS.metaDescLength, 'warn', `Meta description is ${len} characters. Aim for 120–160.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// -- Focus keyword --------------------------------------------------------
|
|
111
|
+
push('focus-keyword', 'Focus keyword', PAGE_SEO_WEIGHTS.focusKeyword, present(input.focusKeyword) ? 'pass' : 'warn', present(input.focusKeyword) ? 'Focus keyword is set.' : 'No focus keyword set.');
|
|
112
|
+
// -- Canonical ------------------------------------------------------------
|
|
113
|
+
push('canonical', 'Canonical URL', PAGE_SEO_WEIGHTS.canonical, present(input.canonicalUrl) ? 'pass' : 'warn', present(input.canonicalUrl) ? 'Canonical URL is set.' : 'No canonical URL set.');
|
|
114
|
+
// -- H1 -------------------------------------------------------------------
|
|
115
|
+
const content = input.content ?? '';
|
|
116
|
+
const h1Count = countH1(content);
|
|
117
|
+
if (h1Count === 1) {
|
|
118
|
+
push('h1', 'H1 heading', PAGE_SEO_WEIGHTS.h1, 'pass', 'Exactly one H1 heading.');
|
|
119
|
+
}
|
|
120
|
+
else if (h1Count === 0) {
|
|
121
|
+
push('h1', 'H1 heading', PAGE_SEO_WEIGHTS.h1, content.length === 0 ? 'warn' : 'fail', 'No H1 heading found.');
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
push('h1', 'H1 heading', PAGE_SEO_WEIGHTS.h1, 'warn', `Found ${h1Count} H1 headings. Use exactly one.`);
|
|
125
|
+
}
|
|
126
|
+
// -- Content length -------------------------------------------------------
|
|
127
|
+
const words = typeof input.wordCount === 'number' ? input.wordCount : wordCountOf(content);
|
|
128
|
+
if (words >= MIN_CONTENT_WORDS) {
|
|
129
|
+
push('content-length', 'Content length', PAGE_SEO_WEIGHTS.contentLength, 'pass', `${words} words — meets the ${MIN_CONTENT_WORDS}-word minimum.`);
|
|
130
|
+
}
|
|
131
|
+
else if (words >= IMPROVE_CONTENT_WORDS) {
|
|
132
|
+
push('content-length', 'Content length', PAGE_SEO_WEIGHTS.contentLength, 'warn', `${words} words. Aim for ${MIN_CONTENT_WORDS}+.`);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
push('content-length', 'Content length', PAGE_SEO_WEIGHTS.contentLength, 'fail', `Only ${words} words. Aim for ${MIN_CONTENT_WORDS}+.`);
|
|
136
|
+
}
|
|
137
|
+
// -- OG image -------------------------------------------------------------
|
|
138
|
+
push('og-image', 'Open Graph image', PAGE_SEO_WEIGHTS.ogImage, present(input.ogImage) ? 'pass' : 'warn', present(input.ogImage) ? 'OG image is set.' : 'No OG image — social shares show a placeholder.');
|
|
139
|
+
// -- OG title / description ----------------------------------------------
|
|
140
|
+
push('og-title-desc', 'Open Graph title & description', PAGE_SEO_WEIGHTS.ogTitleDesc, present(input.ogTitle) || present(input.ogDescription) ? 'pass' : 'warn', present(input.ogTitle) || present(input.ogDescription)
|
|
141
|
+
? 'OG title/description set or inherited.'
|
|
142
|
+
: 'No OG title/description set.');
|
|
143
|
+
// -- Structured data ------------------------------------------------------
|
|
144
|
+
push('structured-data', 'Structured data', PAGE_SEO_WEIGHTS.structuredData, present(input.structuredDataType) ? 'pass' : 'warn', present(input.structuredDataType)
|
|
145
|
+
? `Schema type: ${input.structuredDataType}.`
|
|
146
|
+
: 'No structured data type set.');
|
|
147
|
+
// -- Internal links -------------------------------------------------------
|
|
148
|
+
const broken = input.brokenInternalLinks ?? 0;
|
|
149
|
+
push('internal-links', 'Internal links', PAGE_SEO_WEIGHTS.internalLinks, broken === 0 ? 'pass' : 'fail', broken === 0 ? 'No broken internal links.' : `${broken} broken internal link(s).`);
|
|
150
|
+
// -- Indexability ---------------------------------------------------------
|
|
151
|
+
const shouldIndex = input.shouldBeIndexable !== false;
|
|
152
|
+
const accidentalNoindex = shouldIndex && input.noindex === true;
|
|
153
|
+
push('indexable', 'Indexability', PAGE_SEO_WEIGHTS.indexable, accidentalNoindex ? 'fail' : 'pass', accidentalNoindex
|
|
154
|
+
? 'Page is set to noindex but should be indexable.'
|
|
155
|
+
: 'Indexability is correct.');
|
|
156
|
+
const earned = checks.reduce((sum, c) => sum + c.points, 0);
|
|
157
|
+
const score = Math.max(0, Math.min(100, Math.round(earned)));
|
|
158
|
+
return { score, grade: gradeForSeoScore(score), checks };
|
|
159
|
+
}
|
|
160
|
+
function clampScore(n) {
|
|
161
|
+
if (!Number.isFinite(n))
|
|
162
|
+
return 0;
|
|
163
|
+
return Math.max(0, Math.min(100, n));
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Combine per-category sub-scores into a weighted 0-100 site score.
|
|
167
|
+
*
|
|
168
|
+
* Performance defaults to 70 (neutral) when not supplied, so sites without a
|
|
169
|
+
* connected performance source aren't unfairly penalised — the weight is still
|
|
170
|
+
* applied so connecting Search Console can only improve the signal's accuracy.
|
|
171
|
+
*/
|
|
172
|
+
export function calculateSiteSeoScore(input) {
|
|
173
|
+
const categories = [
|
|
174
|
+
{
|
|
175
|
+
category: 'content',
|
|
176
|
+
score: clampScore(input.content),
|
|
177
|
+
weight: SITE_SEO_WEIGHTS.content,
|
|
178
|
+
contribution: 0,
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
category: 'technical',
|
|
182
|
+
score: clampScore(input.technical),
|
|
183
|
+
weight: SITE_SEO_WEIGHTS.technical,
|
|
184
|
+
contribution: 0,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
category: 'linkRedirect',
|
|
188
|
+
score: clampScore(input.linkRedirect),
|
|
189
|
+
weight: SITE_SEO_WEIGHTS.linkRedirect,
|
|
190
|
+
contribution: 0,
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
category: 'structuredSocial',
|
|
194
|
+
score: clampScore(input.structuredSocial),
|
|
195
|
+
weight: SITE_SEO_WEIGHTS.structuredSocial,
|
|
196
|
+
contribution: 0,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
category: 'performance',
|
|
200
|
+
score: clampScore(input.performance ?? 70),
|
|
201
|
+
weight: SITE_SEO_WEIGHTS.performance,
|
|
202
|
+
contribution: 0,
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
let total = 0;
|
|
206
|
+
for (const c of categories) {
|
|
207
|
+
c.contribution = (c.score * c.weight) / 100;
|
|
208
|
+
total += c.contribution;
|
|
209
|
+
}
|
|
210
|
+
const score = Math.max(0, Math.min(100, Math.round(total)));
|
|
211
|
+
return { score, grade: gradeForSeoScore(score), breakdown: categories };
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=score.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"score.js","sourceRoot":"","sources":["../../src/seo/score.ts"],"names":[],"mappings":"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"}
|
package/dist/setup/index.d.ts
CHANGED
|
@@ -19,9 +19,24 @@ export interface SetupResult {
|
|
|
19
19
|
error?: string;
|
|
20
20
|
userId?: string;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Detect errors that mean the database is unreachable / not initialized,
|
|
24
|
+
* as opposed to an empty-but-healthy database.
|
|
25
|
+
*
|
|
26
|
+
* We must NOT treat these as "needs setup": doing so makes a DB outage
|
|
27
|
+
* silently render the first-run account-creation wizard, and the
|
|
28
|
+
* subsequent create-admin write then fails with an opaque 400. Surfacing
|
|
29
|
+
* the error instead lets callers return a proper 5xx and lets operators
|
|
30
|
+
* see the real cause (e.g. `ECONNREFUSED`, Prisma `P1001`).
|
|
31
|
+
*/
|
|
32
|
+
export declare function isDatabaseUnavailableError(err: unknown): boolean;
|
|
22
33
|
/**
|
|
23
34
|
* Check if the CMS needs first-run setup (no admin users exist).
|
|
24
|
-
* Uses a
|
|
35
|
+
* Uses a count query to avoid loading the full user model.
|
|
36
|
+
*
|
|
37
|
+
* A connection/initialization failure is rethrown so callers can report
|
|
38
|
+
* it as an infrastructure error — only a successful query that returns
|
|
39
|
+
* zero users counts as "setup required".
|
|
25
40
|
*/
|
|
26
41
|
export declare function checkSetupRequired(db: any): Promise<SetupStatus>;
|
|
27
42
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/setup/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,WAAW,WAAW;IAC1B,aAAa,EAAE,OAAO,CAAA;IACtB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/setup/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,WAAW,WAAW;IAC1B,aAAa,EAAE,OAAO,CAAA;IACtB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CA4BhE;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CAAC,EAAE,EAAE,GAAG,GAAG,OAAO,CAAC,WAAW,CAAC,CAQtE;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,CAAC,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,WAAW,CAAC,CAuC/F;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,EAAE,EAAE,GAAG,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAgBxE"}
|
package/dist/setup/index.js
CHANGED
|
@@ -6,16 +6,62 @@
|
|
|
6
6
|
* 2. Automated — env-var-based admin creation for CI/headless deploys
|
|
7
7
|
*/
|
|
8
8
|
import { hashPassword } from '../auth/password.js';
|
|
9
|
+
/**
|
|
10
|
+
* Detect errors that mean the database is unreachable / not initialized,
|
|
11
|
+
* as opposed to an empty-but-healthy database.
|
|
12
|
+
*
|
|
13
|
+
* We must NOT treat these as "needs setup": doing so makes a DB outage
|
|
14
|
+
* silently render the first-run account-creation wizard, and the
|
|
15
|
+
* subsequent create-admin write then fails with an opaque 400. Surfacing
|
|
16
|
+
* the error instead lets callers return a proper 5xx and lets operators
|
|
17
|
+
* see the real cause (e.g. `ECONNREFUSED`, Prisma `P1001`).
|
|
18
|
+
*/
|
|
19
|
+
export function isDatabaseUnavailableError(err) {
|
|
20
|
+
if (!err || typeof err !== 'object')
|
|
21
|
+
return false;
|
|
22
|
+
const code = err.code;
|
|
23
|
+
const name = err.name;
|
|
24
|
+
// Prisma connection/init error codes: can't reach server, timeouts,
|
|
25
|
+
// auth failures, db-does-not-exist, and the raw socket error.
|
|
26
|
+
const connectionCodes = new Set([
|
|
27
|
+
'P1000',
|
|
28
|
+
'P1001',
|
|
29
|
+
'P1002',
|
|
30
|
+
'P1003',
|
|
31
|
+
'P1008',
|
|
32
|
+
'P1017',
|
|
33
|
+
'ECONNREFUSED',
|
|
34
|
+
'ETIMEDOUT',
|
|
35
|
+
'ENOTFOUND',
|
|
36
|
+
]);
|
|
37
|
+
if (typeof code === 'string' && connectionCodes.has(code))
|
|
38
|
+
return true;
|
|
39
|
+
if (name === 'PrismaClientInitializationError')
|
|
40
|
+
return true;
|
|
41
|
+
const message = err.message;
|
|
42
|
+
if (typeof message === 'string') {
|
|
43
|
+
return (message.includes('ECONNREFUSED') ||
|
|
44
|
+
message.includes("Can't reach database server") ||
|
|
45
|
+
message.includes('Authentication failed against database'));
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
9
49
|
/**
|
|
10
50
|
* Check if the CMS needs first-run setup (no admin users exist).
|
|
11
|
-
* Uses a
|
|
51
|
+
* Uses a count query to avoid loading the full user model.
|
|
52
|
+
*
|
|
53
|
+
* A connection/initialization failure is rethrown so callers can report
|
|
54
|
+
* it as an infrastructure error — only a successful query that returns
|
|
55
|
+
* zero users counts as "setup required".
|
|
12
56
|
*/
|
|
13
57
|
export async function checkSetupRequired(db) {
|
|
14
58
|
try {
|
|
15
59
|
const userCount = await db.user.count();
|
|
16
60
|
return { setupRequired: userCount === 0, userCount };
|
|
17
61
|
}
|
|
18
|
-
catch {
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (isDatabaseUnavailableError(err))
|
|
64
|
+
throw err;
|
|
19
65
|
return { setupRequired: true, userCount: 0 };
|
|
20
66
|
}
|
|
21
67
|
}
|
|
@@ -52,6 +98,10 @@ export async function createInitialAdmin(db, input) {
|
|
|
52
98
|
return { success: true, userId: user.id };
|
|
53
99
|
}
|
|
54
100
|
catch (err) {
|
|
101
|
+
// A DB outage is an infrastructure failure, not a bad request —
|
|
102
|
+
// rethrow so the caller can return a 5xx instead of a confusing 400.
|
|
103
|
+
if (isDatabaseUnavailableError(err))
|
|
104
|
+
throw err;
|
|
55
105
|
const message = err instanceof Error ? err.message : 'Setup failed';
|
|
56
106
|
return { success: false, error: message };
|
|
57
107
|
}
|
package/dist/setup/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/setup/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAmBlD
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/setup/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
-- Media Center
|
|
2
|
+
--
|
|
3
|
+
-- Extends actuate_media with the metadata the Media Center needs: SEO fields
|
|
4
|
+
-- (caption / description / focusKeyword), provenance (originalFileName), a
|
|
5
|
+
-- normalised storage split (publicUrl / storageProvider), media processing
|
|
6
|
+
-- artefacts (thumbnailUrl / responsiveVariants / durationSeconds), a tags
|
|
7
|
+
-- bag, soft-delete (deletedAt), and a processing status.
|
|
8
|
+
--
|
|
9
|
+
-- SEO score and issues are deliberately NOT persisted — they are derived on
|
|
10
|
+
-- read by `calculateMediaSeoScore()` so they never drift from the metadata.
|
|
11
|
+
--
|
|
12
|
+
-- Safe to apply to a populated database:
|
|
13
|
+
-- * Every new column is nullable or has a default — no backfill required.
|
|
14
|
+
-- * `status` defaults to READY so existing rows are immediately valid.
|
|
15
|
+
-- * No existing column is dropped or retyped.
|
|
16
|
+
|
|
17
|
+
-- ---------------------------------------------------------------------------
|
|
18
|
+
-- MediaStatus enum
|
|
19
|
+
-- ---------------------------------------------------------------------------
|
|
20
|
+
CREATE TYPE "MediaStatus" AS ENUM (
|
|
21
|
+
'PROCESSING',
|
|
22
|
+
'READY',
|
|
23
|
+
'FAILED'
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
-- ---------------------------------------------------------------------------
|
|
27
|
+
-- actuate_media new columns
|
|
28
|
+
-- ---------------------------------------------------------------------------
|
|
29
|
+
ALTER TABLE "actuate_media"
|
|
30
|
+
ADD COLUMN "originalFileName" TEXT,
|
|
31
|
+
ADD COLUMN "caption" TEXT,
|
|
32
|
+
ADD COLUMN "description" TEXT,
|
|
33
|
+
ADD COLUMN "focusKeyword" TEXT,
|
|
34
|
+
ADD COLUMN "tags" JSONB,
|
|
35
|
+
ADD COLUMN "publicUrl" TEXT,
|
|
36
|
+
ADD COLUMN "storageProvider" TEXT,
|
|
37
|
+
ADD COLUMN "durationSeconds" DOUBLE PRECISION,
|
|
38
|
+
ADD COLUMN "thumbnailUrl" TEXT,
|
|
39
|
+
ADD COLUMN "responsiveVariants" JSONB,
|
|
40
|
+
ADD COLUMN "status" "MediaStatus" NOT NULL DEFAULT 'READY',
|
|
41
|
+
ADD COLUMN "deletedAt" TIMESTAMP(3);
|
|
42
|
+
|
|
43
|
+
-- ---------------------------------------------------------------------------
|
|
44
|
+
-- Indexes backing the new query filters (soft-delete + type filtering)
|
|
45
|
+
-- ---------------------------------------------------------------------------
|
|
46
|
+
CREATE INDEX "actuate_media_deletedAt_idx" ON "actuate_media" ("deletedAt");
|
|
47
|
+
CREATE INDEX "actuate_media_mimeType_idx" ON "actuate_media" ("mimeType");
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
-- SEO Operations Center
|
|
2
|
+
--
|
|
3
|
+
-- Adds the persistence layer for the SEO module:
|
|
4
|
+
-- * Redirect: runtime + management columns (enabled, priority, preserveQuery,
|
|
5
|
+
-- source_kind, hits, lastHitAt, createdById).
|
|
6
|
+
-- * SeoAuditRun: one row per audit execution (score + issue-count snapshot).
|
|
7
|
+
-- * SeoIssue: detected SEO problems, status carried across runs via issueKey.
|
|
8
|
+
-- * Redirect404Hit: aggregated 404 traffic observed by the middleware.
|
|
9
|
+
-- * RedirectSuggestion: confidence-scored 404 recovery / chain suggestions.
|
|
10
|
+
--
|
|
11
|
+
-- Safe to apply to a populated database:
|
|
12
|
+
-- * Every new Redirect column is nullable or has a default — no backfill.
|
|
13
|
+
-- * New tables/enums are additive; no existing column is dropped or retyped.
|
|
14
|
+
|
|
15
|
+
-- ---------------------------------------------------------------------------
|
|
16
|
+
-- Enums
|
|
17
|
+
-- ---------------------------------------------------------------------------
|
|
18
|
+
CREATE TYPE "SeoAuditStatus" AS ENUM ('RUNNING', 'COMPLETED', 'FAILED');
|
|
19
|
+
CREATE TYPE "SeoIssueSeverity" AS ENUM ('critical', 'warning', 'info');
|
|
20
|
+
CREATE TYPE "SeoIssueStatus" AS ENUM ('open', 'ignored', 'resolved', 'recurring');
|
|
21
|
+
CREATE TYPE "SeoIssueCategory" AS ENUM (
|
|
22
|
+
'content', 'technical', 'redirects', 'media',
|
|
23
|
+
'performance', 'schema', 'social', 'indexing'
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
-- ---------------------------------------------------------------------------
|
|
27
|
+
-- actuate_redirects new columns
|
|
28
|
+
-- ---------------------------------------------------------------------------
|
|
29
|
+
ALTER TABLE "actuate_redirects"
|
|
30
|
+
ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true,
|
|
31
|
+
ADD COLUMN "priority" INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
ADD COLUMN "preserveQuery" BOOLEAN NOT NULL DEFAULT false,
|
|
33
|
+
ADD COLUMN "source_kind" TEXT NOT NULL DEFAULT 'manual',
|
|
34
|
+
ADD COLUMN "hits" INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
ADD COLUMN "lastHitAt" TIMESTAMP(3),
|
|
36
|
+
ADD COLUMN "createdById" TEXT;
|
|
37
|
+
|
|
38
|
+
CREATE INDEX "actuate_redirects_enabled_idx" ON "actuate_redirects" ("enabled");
|
|
39
|
+
CREATE INDEX "actuate_redirects_lastHitAt_idx" ON "actuate_redirects" ("lastHitAt");
|
|
40
|
+
|
|
41
|
+
-- ---------------------------------------------------------------------------
|
|
42
|
+
-- actuate_seo_audit_runs
|
|
43
|
+
-- ---------------------------------------------------------------------------
|
|
44
|
+
CREATE TABLE "actuate_seo_audit_runs" (
|
|
45
|
+
"id" TEXT NOT NULL,
|
|
46
|
+
"status" "SeoAuditStatus" NOT NULL DEFAULT 'RUNNING',
|
|
47
|
+
"scope" TEXT NOT NULL DEFAULT 'full',
|
|
48
|
+
"startedById" TEXT,
|
|
49
|
+
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
50
|
+
"finishedAt" TIMESTAMP(3),
|
|
51
|
+
"siteScoreBefore" INTEGER,
|
|
52
|
+
"siteScoreAfter" INTEGER,
|
|
53
|
+
"pagesScanned" INTEGER NOT NULL DEFAULT 0,
|
|
54
|
+
"issuesFound" INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
"criticalCount" INTEGER NOT NULL DEFAULT 0,
|
|
56
|
+
"warningCount" INTEGER NOT NULL DEFAULT 0,
|
|
57
|
+
"infoCount" INTEGER NOT NULL DEFAULT 0,
|
|
58
|
+
"errorMessage" TEXT,
|
|
59
|
+
"metadata" JSONB,
|
|
60
|
+
CONSTRAINT "actuate_seo_audit_runs_pkey" PRIMARY KEY ("id")
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE INDEX "actuate_seo_audit_runs_status_idx" ON "actuate_seo_audit_runs" ("status");
|
|
64
|
+
CREATE INDEX "actuate_seo_audit_runs_startedAt_idx" ON "actuate_seo_audit_runs" ("startedAt");
|
|
65
|
+
|
|
66
|
+
-- ---------------------------------------------------------------------------
|
|
67
|
+
-- actuate_seo_issues
|
|
68
|
+
-- ---------------------------------------------------------------------------
|
|
69
|
+
CREATE TABLE "actuate_seo_issues" (
|
|
70
|
+
"id" TEXT NOT NULL,
|
|
71
|
+
"auditRunId" TEXT,
|
|
72
|
+
"issueKey" TEXT NOT NULL,
|
|
73
|
+
"entityType" TEXT,
|
|
74
|
+
"entityId" TEXT,
|
|
75
|
+
"entityTitle" TEXT,
|
|
76
|
+
"url" TEXT,
|
|
77
|
+
"severity" "SeoIssueSeverity" NOT NULL,
|
|
78
|
+
"category" "SeoIssueCategory" NOT NULL,
|
|
79
|
+
"type" TEXT NOT NULL,
|
|
80
|
+
"title" TEXT NOT NULL,
|
|
81
|
+
"description" TEXT,
|
|
82
|
+
"impact" TEXT,
|
|
83
|
+
"recommendation" TEXT,
|
|
84
|
+
"status" "SeoIssueStatus" NOT NULL DEFAULT 'open',
|
|
85
|
+
"fixable" BOOLEAN NOT NULL DEFAULT false,
|
|
86
|
+
"fixActionType" TEXT,
|
|
87
|
+
"relatedEntityIds" JSONB,
|
|
88
|
+
"source" TEXT NOT NULL DEFAULT 'audit',
|
|
89
|
+
"firstDetectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
90
|
+
"lastDetectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
91
|
+
"resolvedAt" TIMESTAMP(3),
|
|
92
|
+
"resolvedById" TEXT,
|
|
93
|
+
"metadata" JSONB,
|
|
94
|
+
CONSTRAINT "actuate_seo_issues_pkey" PRIMARY KEY ("id")
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
CREATE INDEX "actuate_seo_issues_auditRunId_idx" ON "actuate_seo_issues" ("auditRunId");
|
|
98
|
+
CREATE INDEX "actuate_seo_issues_status_idx" ON "actuate_seo_issues" ("status");
|
|
99
|
+
CREATE INDEX "actuate_seo_issues_severity_idx" ON "actuate_seo_issues" ("severity");
|
|
100
|
+
CREATE INDEX "actuate_seo_issues_category_idx" ON "actuate_seo_issues" ("category");
|
|
101
|
+
CREATE INDEX "actuate_seo_issues_issueKey_idx" ON "actuate_seo_issues" ("issueKey");
|
|
102
|
+
|
|
103
|
+
ALTER TABLE "actuate_seo_issues"
|
|
104
|
+
ADD CONSTRAINT "actuate_seo_issues_auditRunId_fkey"
|
|
105
|
+
FOREIGN KEY ("auditRunId") REFERENCES "actuate_seo_audit_runs"("id")
|
|
106
|
+
ON DELETE SET NULL ON UPDATE CASCADE;
|
|
107
|
+
|
|
108
|
+
-- ---------------------------------------------------------------------------
|
|
109
|
+
-- actuate_redirect_404_hits
|
|
110
|
+
-- ---------------------------------------------------------------------------
|
|
111
|
+
CREATE TABLE "actuate_redirect_404_hits" (
|
|
112
|
+
"id" TEXT NOT NULL,
|
|
113
|
+
"path" TEXT NOT NULL,
|
|
114
|
+
"hits" INTEGER NOT NULL DEFAULT 1,
|
|
115
|
+
"referrer" TEXT,
|
|
116
|
+
"resolvedAt" TIMESTAMP(3),
|
|
117
|
+
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
118
|
+
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
119
|
+
CONSTRAINT "actuate_redirect_404_hits_pkey" PRIMARY KEY ("id")
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
CREATE UNIQUE INDEX "actuate_redirect_404_hits_path_key" ON "actuate_redirect_404_hits" ("path");
|
|
123
|
+
CREATE INDEX "actuate_redirect_404_hits_lastSeenAt_idx" ON "actuate_redirect_404_hits" ("lastSeenAt");
|
|
124
|
+
|
|
125
|
+
-- ---------------------------------------------------------------------------
|
|
126
|
+
-- actuate_redirect_suggestions
|
|
127
|
+
-- ---------------------------------------------------------------------------
|
|
128
|
+
CREATE TABLE "actuate_redirect_suggestions" (
|
|
129
|
+
"id" TEXT NOT NULL,
|
|
130
|
+
"fromPath" TEXT NOT NULL,
|
|
131
|
+
"toPath" TEXT NOT NULL,
|
|
132
|
+
"confidence" INTEGER NOT NULL DEFAULT 0,
|
|
133
|
+
"reason" TEXT,
|
|
134
|
+
"status" TEXT NOT NULL DEFAULT 'open',
|
|
135
|
+
"source" TEXT NOT NULL DEFAULT 'ai-404-recovery',
|
|
136
|
+
"metadata" JSONB,
|
|
137
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
138
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
139
|
+
CONSTRAINT "actuate_redirect_suggestions_pkey" PRIMARY KEY ("id")
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
CREATE INDEX "actuate_redirect_suggestions_status_idx" ON "actuate_redirect_suggestions" ("status");
|
|
143
|
+
CREATE INDEX "actuate_redirect_suggestions_fromPath_idx" ON "actuate_redirect_suggestions" ("fromPath");
|