@tekki-forge/runtime-api 0.1.0 → 0.3.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/site.js ADDED
@@ -0,0 +1,1413 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { buildSiteChrome, checkSiteSection, contentHash, defaultDataFields, isUploadedImage, PAGE_TEMPLATE_NAMES, POST_KINDS, sectionDefaults, sitePathProblem, templateContext, templatePage, validHref, validImage, } from "@tekki-forge/compiler";
3
+ import { BadRequestException, ConflictException, ForbiddenException, HttpException, HttpStatus, Logger, NotFoundException, ServiceUnavailableException, UnauthorizedException } from "@nestjs/common";
4
+ import { resolveTokens } from "./dashboard.js";
5
+ import { parseListQuery, parseSort, QueryError } from "./query.js";
6
+ import { relationInclude } from "./serialize.js";
7
+ // ─── Access ──────────────────────────────────────────────────────────────────────────────────
8
+ /** Global user roles of the caller (the `role` column, comma-separated), as the admin pages use them. */
9
+ const globalRoles = (ctx) => (ctx.user?.role ?? "").split(",").map((r) => r.trim()).filter(Boolean);
10
+ /** A signed-in person (never an API key) whose global roles include one of `site.editors`. */
11
+ export function canEditSite(app, ctx) {
12
+ if (!app.site || ctx.apiKey || !ctx.principal || !ctx.user)
13
+ return false;
14
+ const editors = app.site.editors.length ? app.site.editors : ["admin"];
15
+ return globalRoles(ctx).some((r) => editors.includes(r));
16
+ }
17
+ /** Visitors: what the website shows is computed as nobody in particular. */
18
+ const ANONYMOUS = { principal: null, user: null, session: null };
19
+ const invalid = (issues) => new BadRequestException({ statusCode: 400, error: "Bad Request", message: "Validation failed", issues });
20
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
21
+ const iso = (d) => (d ? new Date(d).toISOString() : null);
22
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
24
+ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
25
+ const MAX_PAGE_SECTIONS = 40;
26
+ // ─── Tables missing (schema not migrated yet) ────────────────────────────────────────────────
27
+ const logger = new Logger("ForgeSite");
28
+ let warnedMissing = false;
29
+ /** Prisma "table/column does not exist" (P2021/P2022), or a client generated before the site models. */
30
+ export function siteTablesMissing(e) {
31
+ if (e instanceof SiteTablesMissing)
32
+ return true;
33
+ const err = e;
34
+ if (err?.code === "P2021" || err?.code === "P2022")
35
+ return true;
36
+ const message = err?.message ?? "";
37
+ return /does not exist/.test(message) && /site_(pages|sections|posts)|contact_messages|newsletter_subscribers/.test(message);
38
+ }
39
+ class SiteTablesMissing extends Error {
40
+ constructor() {
41
+ super("The Prisma client has no site models");
42
+ }
43
+ }
44
+ function warnMissing() {
45
+ if (warnedMissing)
46
+ return;
47
+ warnedMissing = true;
48
+ logger.warn("The website tables are missing — run `forge db migrate` (until then the site is shown straight from forge.config.ts and cannot be edited)");
49
+ }
50
+ const MIGRATE = "The website tables are missing — run forge db migrate";
51
+ /**
52
+ * What to do with a row that comes from the config and is still in it:
53
+ * - keep: nothing changed since the last sync (or the conflict is already flagged);
54
+ * - apply: the config changed and nobody edited the row in the admin → take the config version;
55
+ * - conflict: both changed (or the admin deleted it) → keep the admin version, flag it;
56
+ * - resolve: the config went back to the version last applied → the conflict is gone.
57
+ */
58
+ export function syncDecision(row, hash) {
59
+ if (row.configHash === hash)
60
+ return row.conflict ? "resolve" : "keep";
61
+ if (row.edited || row.removed)
62
+ return row.conflict ? "keep" : "conflict";
63
+ return "apply";
64
+ }
65
+ /** The page columns a config page sets. */
66
+ export function configPageFields(p) {
67
+ return { title: p.title, path: p.path, template: p.template ?? null, seo: p.seo, nav: p.nav, redirect: p.redirect ?? null, status: p.status };
68
+ }
69
+ const configSectionFields = (s) => ({ type: s.type, variant: s.variant, hidden: s.hidden, props: s.props });
70
+ function freePath(path, taken) {
71
+ const base = `${path === "/" ? "/home" : path}-config`;
72
+ let candidate = base;
73
+ for (let n = 2; taken.has(candidate); n++)
74
+ candidate = `${base}-${n}`;
75
+ return candidate;
76
+ }
77
+ /**
78
+ * Pure part of the sync: compare the config (SiteIR) with the stored pages and list the writes to make.
79
+ * Admin work always wins over the config; conflicts are flagged, never overwritten.
80
+ */
81
+ export function planSiteSync(site, rows, now = new Date()) {
82
+ const ops = [];
83
+ const report = { created: 0, updated: 0, conflicts: 0, removed: 0 };
84
+ const inConfig = new Set(site.pages.map((p) => p.key));
85
+ // Paths in use (unique column), kept current as the plan moves pages around.
86
+ const paths = new Map(rows.map((r) => [r.path, r.id]));
87
+ // Config pages that left the config: deleted, unless someone worked on them in the admin.
88
+ for (const row of rows) {
89
+ if (row.origin !== "config" || (row.key !== null && inConfig.has(row.key)))
90
+ continue;
91
+ const adminWork = row.edited || row.sections.some((s) => !s.removed && (s.origin !== "config" || s.edited));
92
+ if (!adminWork) {
93
+ ops.push({ op: "deletePage", id: row.id });
94
+ paths.delete(row.path);
95
+ report.removed++;
96
+ continue;
97
+ }
98
+ ops.push({ op: "updatePage", id: row.id, data: { origin: "admin", key: null, configHash: null, conflict: false } });
99
+ report.updated++;
100
+ for (const s of row.sections) {
101
+ if (s.removed) {
102
+ ops.push({ op: "deleteSection", id: s.id });
103
+ report.removed++;
104
+ }
105
+ else if (s.origin === "config")
106
+ ops.push({ op: "updateSection", id: s.id, data: { origin: "admin", configHash: null, conflict: false } });
107
+ }
108
+ }
109
+ const byKey = new Map(rows.filter((r) => r.origin === "config" && r.key !== null).map((r) => [r.key, r]));
110
+ site.pages.forEach((p, index) => {
111
+ const row = byKey.get(p.key);
112
+ if (!row) {
113
+ // An admin page already has this path: the config page comes in as a draft beside it, flagged.
114
+ const collision = paths.has(p.path);
115
+ const path = collision ? freePath(p.path, paths) : p.path;
116
+ const status = collision ? "draft" : p.status;
117
+ paths.set(path, `new:${p.key}`);
118
+ const { nav, ...fields } = configPageFields(p);
119
+ ops.push({
120
+ op: "createPage",
121
+ data: { ...fields, ...(nav !== null ? { nav } : {}), key: p.key, path, status, origin: "config", configHash: p.hash, conflict: collision, position: index, publishedAt: status === "published" ? now : null },
122
+ sections: p.sections.map((s, j) => ({ key: s.key, ...configSectionFields(s), origin: "config", configHash: s.hash, position: j })),
123
+ });
124
+ report.created += 1 + p.sections.length;
125
+ if (collision)
126
+ report.conflicts++;
127
+ return;
128
+ }
129
+ const page = {};
130
+ const decision = syncDecision(row, p.hash);
131
+ if (decision === "apply") {
132
+ const owner = paths.get(p.path);
133
+ if (owner !== undefined && owner !== row.id) {
134
+ // The new path belongs to another page: keep this one as it is and let the admin sort it out.
135
+ if (!row.conflict) {
136
+ page.conflict = true;
137
+ report.conflicts++;
138
+ }
139
+ }
140
+ else {
141
+ paths.delete(row.path);
142
+ paths.set(p.path, row.id);
143
+ Object.assign(page, configPageFields(p), { configHash: p.hash });
144
+ if (p.status === "published" && !row.publishedAt)
145
+ page.publishedAt = now;
146
+ report.updated++;
147
+ }
148
+ }
149
+ else if (decision === "conflict") {
150
+ page.conflict = true;
151
+ report.conflicts++;
152
+ }
153
+ else if (decision === "resolve")
154
+ page.conflict = false;
155
+ if (row.position !== index)
156
+ page.position = index;
157
+ if (Object.keys(page).length)
158
+ ops.push({ op: "updatePage", id: row.id, data: page });
159
+ const declared = new Map(p.sections.map((s) => [s.key, s]));
160
+ const stored = new Map(row.sections.map((s) => [s.key, s]));
161
+ for (const s of row.sections) {
162
+ if (s.origin !== "config" || declared.has(s.key))
163
+ continue;
164
+ if (s.edited && !s.removed) {
165
+ ops.push({ op: "updateSection", id: s.id, data: { origin: "admin", configHash: null, conflict: false } });
166
+ report.updated++;
167
+ }
168
+ else {
169
+ ops.push({ op: "deleteSection", id: s.id });
170
+ report.removed++;
171
+ }
172
+ }
173
+ // Reordered in the admin: admin positions stay, new config sections go to the end.
174
+ let next = Math.max(-1, ...row.sections.map((s) => s.position)) + 1;
175
+ p.sections.forEach((s, j) => {
176
+ const existing = stored.get(s.key);
177
+ const position = row.orderEdited ? undefined : j;
178
+ if (!existing) {
179
+ ops.push({ op: "createSection", data: { pageId: row.id, key: s.key, ...configSectionFields(s), origin: "config", configHash: s.hash, position: position ?? next++ } });
180
+ report.created++;
181
+ return;
182
+ }
183
+ const data = {};
184
+ if (existing.origin !== "config") {
185
+ // A section kept from an earlier config is back in the config: adopt it, keeping the admin version.
186
+ Object.assign(data, { origin: "config", edited: true, conflict: true });
187
+ report.conflicts++;
188
+ }
189
+ else {
190
+ const d = syncDecision(existing, s.hash);
191
+ if (d === "apply") {
192
+ Object.assign(data, configSectionFields(s), { configHash: s.hash });
193
+ report.updated++;
194
+ }
195
+ else if (d === "conflict") {
196
+ data.conflict = true;
197
+ report.conflicts++;
198
+ }
199
+ else if (d === "resolve")
200
+ data.conflict = false;
201
+ }
202
+ if (position !== undefined && existing.position !== position)
203
+ data.position = position;
204
+ if (Object.keys(data).length)
205
+ ops.push({ op: "updateSection", id: existing.id, data });
206
+ });
207
+ });
208
+ return { ops, report };
209
+ }
210
+ /** Arbitrary constant: one sync at a time across every API process sharing the database. */
211
+ const SITE_SYNC_LOCK = 4_242_017_311;
212
+ /** `nav` is a nullable JSON column: SQL NULL (automatic menu) can't be written through Prisma without the generated `Prisma.DbNull`. */
213
+ async function writeNav(db, id, nav) {
214
+ if (nav === null)
215
+ await db.$executeRawUnsafe(`UPDATE "site_pages" SET "nav" = NULL WHERE "id" = $1::uuid`, id);
216
+ }
217
+ async function runOps(db, ops) {
218
+ for (const o of ops) {
219
+ if (o.op === "createPage")
220
+ await db.sitePage.create({ data: { ...o.data, sections: { create: o.sections } } });
221
+ else if (o.op === "updatePage") {
222
+ const { nav, ...data } = o.data;
223
+ await db.sitePage.update({ where: { id: o.id }, data: { ...data, ...(nav !== undefined && nav !== null ? { nav } : {}) } });
224
+ await writeNav(db, o.id, nav === undefined ? undefined : nav);
225
+ }
226
+ else if (o.op === "deletePage")
227
+ await db.sitePage.delete({ where: { id: o.id } });
228
+ else if (o.op === "createSection")
229
+ await db.siteSection.create({ data: o.data });
230
+ else if (o.op === "updateSection")
231
+ await db.siteSection.update({ where: { id: o.id }, data: o.data });
232
+ else
233
+ await db.siteSection.delete({ where: { id: o.id } });
234
+ }
235
+ }
236
+ /**
237
+ * Seed the website from forge.config.ts (idempotent): new pages and sections are inserted, untouched ones follow
238
+ * the config, admin edits are kept (and flagged when the config changed too). Runs at API start and in `forge db seed`,
239
+ * under a transaction-level advisory lock so several API processes starting together don't race. Never throws:
240
+ * returns null when there is no site or the tables are missing (with a "run forge db migrate" warning).
241
+ */
242
+ export async function syncSite(app, prisma, options = {}) {
243
+ if (!app.site)
244
+ return null;
245
+ if (!prisma?.sitePage) {
246
+ warnMissing();
247
+ return null;
248
+ }
249
+ try {
250
+ const report = await prisma.$transaction(async (tx) => {
251
+ await tx.$executeRawUnsafe(`SELECT pg_advisory_xact_lock(${SITE_SYNC_LOCK})`);
252
+ const rows = await tx.sitePage.findMany({
253
+ select: { id: true, key: true, path: true, origin: true, configHash: true, edited: true, conflict: true, orderEdited: true, position: true, publishedAt: true, sections: { select: { id: true, key: true, origin: true, configHash: true, edited: true, conflict: true, removed: true, position: true } } },
254
+ });
255
+ const plan = planSiteSync(app.site, rows);
256
+ await runOps(tx, plan.ops);
257
+ return plan.report;
258
+ }, { timeout: 60_000, maxWait: 30_000 });
259
+ if (options.log !== false && (report.created || report.updated || report.conflicts || report.removed)) {
260
+ logger.log(`Website synced from forge.config.ts: ${report.created} created, ${report.updated} updated, ${report.removed} removed, ${report.conflicts} conflict(s)${report.conflicts ? " — review them in the admin (Content)" : ""}`);
261
+ }
262
+ return report;
263
+ }
264
+ catch (e) {
265
+ if (siteTablesMissing(e))
266
+ warnMissing();
267
+ else
268
+ logger.error(`Website sync failed: ${e.message}`);
269
+ return null;
270
+ }
271
+ }
272
+ /** Bot traps: the hidden "website" field is filled, or the form was sent less than 2 s after it was shown. */
273
+ export function looksLikeSpam(body, now = Date.now()) {
274
+ if (typeof body.website === "string" && body.website.trim() !== "")
275
+ return true;
276
+ // Only a recent timestamp counts: a client clock running ahead (negative elapsed time) is not held against people.
277
+ if (typeof body.startedAt === "number" && Number.isFinite(body.startedAt)) {
278
+ const elapsed = now - body.startedAt;
279
+ if (elapsed >= 0 && elapsed < 2000)
280
+ return true;
281
+ }
282
+ return false;
283
+ }
284
+ const text = (v) => (typeof v === "string" ? v.trim() : v);
285
+ export function validateContact(body) {
286
+ const b = isObject(body) ? body : {};
287
+ const issues = [];
288
+ const str = (key, min, max, required) => {
289
+ const v = text(b[key]);
290
+ if (v === undefined || v === null || v === "") {
291
+ if (required)
292
+ issues.push({ path: key, message: "Required" });
293
+ return undefined;
294
+ }
295
+ if (typeof v !== "string")
296
+ issues.push({ path: key, message: "Expected text" });
297
+ else if (v.length < min)
298
+ issues.push({ path: key, message: `Must be at least ${min} characters` });
299
+ else if (v.length > max)
300
+ issues.push({ path: key, message: `Must be at most ${max} characters` });
301
+ else
302
+ return v;
303
+ return undefined;
304
+ };
305
+ const name = str("name", 1, 100, true);
306
+ const email = str("email", 3, 200, true);
307
+ if (email !== undefined && !EMAIL.test(email))
308
+ issues.push({ path: "email", message: "Must be an email address" });
309
+ const company = str("company", 1, 100, false);
310
+ const message = str("message", 10, 5000, true);
311
+ const page = str("page", 1, 200, false);
312
+ return { issues, value: { name: name ?? "", email: (email ?? "").toLowerCase(), message: message ?? "", ...(company ? { company } : {}), ...(page ? { page } : {}) } };
313
+ }
314
+ export function validateNewsletter(body) {
315
+ const b = isObject(body) ? body : {};
316
+ const issues = [];
317
+ const email = text(b.email);
318
+ if (email === undefined || email === null || email === "")
319
+ issues.push({ path: "email", message: "Required" });
320
+ else if (typeof email !== "string" || email.length > 200 || !EMAIL.test(email))
321
+ issues.push({ path: "email", message: "Must be an email address" });
322
+ const source = text(b.source);
323
+ if (source !== undefined && source !== null && source !== "" && (typeof source !== "string" || source.length > 100))
324
+ issues.push({ path: "source", message: "Must be at most 100 characters" });
325
+ return { issues, value: { email: typeof email === "string" ? email.toLowerCase() : "", ...(typeof source === "string" && source ? { source } : {}) } };
326
+ }
327
+ /** In-memory sliding-window limits per IP (per API process — enough to stop a form from being flooded). */
328
+ const hits = new Map();
329
+ const WINDOW = 10 * 60_000;
330
+ function rateLimited(key, max, now = Date.now()) {
331
+ const recent = (hits.get(key) ?? []).filter((t) => now - t < WINDOW);
332
+ if (recent.length >= max) {
333
+ hits.set(key, recent);
334
+ return true;
335
+ }
336
+ recent.push(now);
337
+ hits.set(key, recent);
338
+ if (hits.size > 10_000)
339
+ for (const [k, v] of hits)
340
+ if (!v.some((t) => now - t < WINDOW))
341
+ hits.delete(k);
342
+ return false;
343
+ }
344
+ /** Forget every contact/newsletter rate limit (tests). */
345
+ export function resetSiteRateLimits() {
346
+ hits.clear();
347
+ }
348
+ const tooMany = () => new HttpException({ statusCode: 429, error: "Too Many Requests", message: "Too many messages from your network — try again in a few minutes" }, HttpStatus.TOO_MANY_REQUESTS);
349
+ // ─── Posts ───────────────────────────────────────────────────────────────────────────────────
350
+ /** "Hello, World!" → "hello-world" (accents dropped, at most 100 characters). */
351
+ export function slugify(input) {
352
+ return (input
353
+ .normalize("NFKD")
354
+ .replace(/[̀-ͯ]/g, "")
355
+ .toLowerCase()
356
+ .replace(/[^a-z0-9]+/g, "-")
357
+ .replace(/^-+|-+$/g, "")
358
+ .slice(0, 100)
359
+ .replace(/-+$/, "") || "post");
360
+ }
361
+ function seoInput(v, at, issues) {
362
+ if (v === undefined)
363
+ return undefined;
364
+ if (v === null)
365
+ return {};
366
+ if (!isObject(v)) {
367
+ issues.push({ path: at, message: "Must be { title?, description?, image? }" });
368
+ return undefined;
369
+ }
370
+ const out = {};
371
+ for (const k of Object.keys(v))
372
+ if (!["title", "description", "image"].includes(k))
373
+ issues.push({ path: `${at}.${k}`, message: "Unknown key (seo has title, description, image)" });
374
+ const t = text(v.title);
375
+ if (t !== undefined && t !== null && t !== "")
376
+ typeof t === "string" && t.length <= 120 ? (out.title = t) : issues.push({ path: `${at}.title`, message: "Must be at most 120 characters" });
377
+ const d = text(v.description);
378
+ if (d !== undefined && d !== null && d !== "")
379
+ typeof d === "string" && d.length <= 300 ? (out.description = d) : issues.push({ path: `${at}.description`, message: "Must be at most 300 characters" });
380
+ if (v.image !== undefined && v.image !== null && v.image !== "")
381
+ validImage(v.image) ? (out.image = v.image) : issues.push({ path: `${at}.image`, message: 'Must be an image URL (https://…) or a path such as "/og.png"' });
382
+ return out;
383
+ }
384
+ const SAMPLE_POSTS = {
385
+ post: [
386
+ { title: "Welcome to our new website", excerpt: "What we are building, and why.", tags: ["news"] },
387
+ { title: "Five tips to get organized", excerpt: "Small habits that keep a team's work on track.", tags: ["tips"] },
388
+ { title: "What's new this month", excerpt: "The latest improvements, big and small.", tags: ["product"] },
389
+ ],
390
+ guide: [
391
+ { title: "Getting started in ten minutes", excerpt: "From your first sign-in to a working setup.", tags: ["basics"] },
392
+ { title: "Inviting your team", excerpt: "Roles, permissions and the first invitations.", tags: ["teams"] },
393
+ ],
394
+ webinar: [
395
+ { title: "Live demo: a tour of the product", excerpt: "A recorded walkthrough with questions from the audience.", tags: ["demo"] },
396
+ { title: "Best practices from our customers", excerpt: "How teams like yours get the most out of it.", tags: ["customers"] },
397
+ ],
398
+ "case-study": [
399
+ { title: "How Northwind saved ten hours a week", excerpt: "From spreadsheets to one shared source of truth.", tags: ["operations"] },
400
+ { title: "Contoso's rollout to 200 people", excerpt: "A step-by-step migration without downtime.", tags: ["enterprise"] },
401
+ ],
402
+ };
403
+ /** A few published posts per post kind with a public listing, when there are no posts yet (`forge db seed`). */
404
+ export async function seedSitePosts(app, prisma, userId) {
405
+ if (!app.site || !prisma?.sitePost)
406
+ return 0;
407
+ const kinds = Object.keys(app.site.blog);
408
+ if (!kinds.length || (await prisma.sitePost.count()) > 0)
409
+ return 0;
410
+ const author = userId ? await prisma.user.findUnique({ where: { id: userId }, select: { name: true } }) : null;
411
+ let n = 0;
412
+ for (const kind of kinds) {
413
+ for (const [i, p] of SAMPLE_POSTS[kind].entries()) {
414
+ await prisma.sitePost.create({
415
+ data: {
416
+ kind,
417
+ slug: slugify(p.title),
418
+ title: p.title,
419
+ excerpt: p.excerpt,
420
+ body: `${p.excerpt}\n\n## Why it matters\n\nThis is a sample ${kind === "case-study" ? "case study" : kind} written by \`forge db seed\`. Edit or delete it in the admin (**Content → Posts**).\n\n- Clear and short\n- Written in markdown\n- Published on the website at once`,
421
+ tags: p.tags,
422
+ status: "published",
423
+ publishedAt: new Date(Date.now() - (i + 1) * 3 * 86_400_000),
424
+ authorId: userId,
425
+ authorName: author?.name ?? `The ${app.name} team`,
426
+ },
427
+ });
428
+ n++;
429
+ }
430
+ }
431
+ return n;
432
+ }
433
+ // ─── Live data of sections ───────────────────────────────────────────────────────────────────
434
+ const NUMBER = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
435
+ /** A section filter ({ status: "open", "price.gte": 10, tags: ["a", "b"] }) as list-query parameters. */
436
+ function filterQuery(filter) {
437
+ const out = {};
438
+ if (!isObject(filter))
439
+ return out;
440
+ for (const [k, v] of Object.entries(filter))
441
+ out[k] = Array.isArray(v) ? v.map(String).join(",") : String(v);
442
+ return out;
443
+ }
444
+ // ─── Service ─────────────────────────────────────────────────────────────────────────────────
445
+ /**
446
+ * The public website: rendering for visitors (pages, posts, sitemap), the contact form and newsletter, and the
447
+ * admin API of site editors (pages, sections, posts, messages, subscribers). Visitors only ever see published
448
+ * content and data of public, non-tenant entities, computed with an anonymous context.
449
+ */
450
+ export class SiteService {
451
+ service;
452
+ constructor(service) {
453
+ this.service = service;
454
+ }
455
+ get app() {
456
+ return this.service.app;
457
+ }
458
+ get prisma() {
459
+ return this.service.prisma;
460
+ }
461
+ get site() {
462
+ if (!this.app.site)
463
+ throw new NotFoundException("The website is not enabled");
464
+ return this.app.site;
465
+ }
466
+ /** The site models of the Prisma client (absent when it was generated before the site existed). */
467
+ get db() {
468
+ if (!this.prisma.sitePage)
469
+ throw new SiteTablesMissing();
470
+ return this.prisma;
471
+ }
472
+ /** Admin routes: a signed-in site editor, never an API key. */
473
+ editor(ctx) {
474
+ void this.site;
475
+ if (ctx.apiKey)
476
+ throw new ForbiddenException("API keys cannot manage the website");
477
+ if (!ctx.principal)
478
+ throw new UnauthorizedException("Sign in to continue");
479
+ if (!canEditSite(this.app, ctx))
480
+ throw new ForbiddenException("Only site editors can manage the website");
481
+ }
482
+ /** Turn "tables missing" into a clear 503 on admin and form routes. */
483
+ async guard(fn) {
484
+ try {
485
+ return await fn();
486
+ }
487
+ catch (e) {
488
+ if (siteTablesMissing(e)) {
489
+ warnMissing();
490
+ throw new ServiceUnavailableException(MIGRATE);
491
+ }
492
+ throw e;
493
+ }
494
+ }
495
+ checkApp() {
496
+ return { entities: this.app.entities, plugins: this.app.plugins, embedHosts: this.site.embedHosts };
497
+ }
498
+ templateCtx() {
499
+ return templateContext({ appName: this.app.name, ...(this.app.description ? { description: this.app.description } : {}), registration: this.app.auth.registration, entities: this.app.entities, plugins: this.app.plugins });
500
+ }
501
+ takenPaths() {
502
+ return {
503
+ entityPaths: this.app.entities.map((e) => e.path),
504
+ pluginPaths: [...new Set(this.app.plugins.flatMap((p) => [...p.reservedPaths, ...p.nav.map((n) => n.path.split("/")[0])]))],
505
+ };
506
+ }
507
+ configPage(key) {
508
+ return key ? this.site.pages.find((p) => p.key === key) : undefined;
509
+ }
510
+ // ─── Section data ─────────────────────────────────────────────────────────────
511
+ /** Live values of a section (stats, records, posts, plans); undefined for sections without data or on failure. */
512
+ async sectionData(type, variant, props) {
513
+ try {
514
+ switch (type) {
515
+ case "stats":
516
+ return await this.stats(variant, props);
517
+ case "entityList":
518
+ case "entityGrid":
519
+ return await this.records(type, variant, props);
520
+ case "blogList": {
521
+ const kind = POST_KINDS.includes(props.kind) ? props.kind : "post";
522
+ const limit = typeof props.limit === "number" ? Math.min(24, Math.max(1, props.limit)) : 6;
523
+ const { data } = await this.publishedPosts({ kind, limit, page: 1, ...(typeof props.tag === "string" ? { tag: props.tag } : {}) });
524
+ return { kind: "posts", posts: data };
525
+ }
526
+ case "pricing": {
527
+ if (props.source !== "payments")
528
+ return undefined;
529
+ const plugin = this.app.plugins.find((p) => p.name === "payments");
530
+ const plans = plugin?.options.plans ?? [];
531
+ return { kind: "plans", plans: plans.map((p) => ({ id: String(p.id), name: String(p.name), price: p.price ?? null, description: p.description ?? null, features: Array.isArray(p.features) ? p.features.map(String) : [] })) };
532
+ }
533
+ default:
534
+ return undefined;
535
+ }
536
+ }
537
+ catch (e) {
538
+ if (!siteTablesMissing(e))
539
+ logger.warn(`Website ${type} section data failed: ${e.message}`);
540
+ return undefined;
541
+ }
542
+ }
543
+ /** Static values as typed; live ones aggregated like dashboard metrics, as an anonymous visitor (public entities only). */
544
+ async stats(variant, props) {
545
+ const items = Array.isArray(props.items) ? props.items : [];
546
+ const checked = checkSiteSection(this.checkApp(), { type: "stats", variant, props }, "section");
547
+ const values = await Promise.all(items.map(async (item, i) => {
548
+ if (item.entity === undefined)
549
+ return typeof item.value === "string" ? item.value : null;
550
+ if (checked.issues.some((x) => x.path === `section.items[${i}]` || x.path.startsWith(`section.items[${i}].`)))
551
+ return null;
552
+ const entity = this.service.entityByName(String(item.entity));
553
+ if (!entity)
554
+ return null;
555
+ try {
556
+ const aggregate = item.aggregate ?? "count";
557
+ const where = this.publicWhere(entity, item.where);
558
+ const d = this.service.delegate(entity);
559
+ let v;
560
+ if (aggregate === "count")
561
+ v = await d.count({ where });
562
+ else {
563
+ const r = await d.aggregate({ where, [`_${aggregate}`]: { [item.field]: true } });
564
+ v = r[`_${aggregate}`]?.[item.field];
565
+ if ((v === null || v === undefined) && aggregate === "sum")
566
+ v = 0;
567
+ }
568
+ if (v === null || v === undefined)
569
+ return null;
570
+ const n = Number(typeof v === "object" ? String(v) : v);
571
+ return Number.isFinite(n) ? NUMBER.format(n) : null;
572
+ }
573
+ catch (e) {
574
+ if (!(e instanceof QueryError))
575
+ logger.warn(`Website stat "${String(item.label)}" failed: ${e.message}`);
576
+ return null;
577
+ }
578
+ }));
579
+ return { kind: "stats", values };
580
+ }
581
+ /** Read scope of an anonymous visitor + the section's filter (same rules as the dashboard and the list API). */
582
+ publicWhere(entity, filter) {
583
+ const access = { ...this.service.queryAccess(entity, ANONYMOUS), anyField: true };
584
+ const filters = parseListQuery(entity, resolveTokens(filterQuery(filter), null), access).where;
585
+ return { AND: [...this.service.readWhere(entity, ANONYMOUS), ...filters] };
586
+ }
587
+ async records(type, variant, props) {
588
+ const checked = checkSiteSection(this.checkApp(), { type, variant, props }, "section");
589
+ if (checked.issues.length)
590
+ return undefined;
591
+ const entity = this.service.entityByName(String(checked.props.entity));
592
+ if (!entity)
593
+ return undefined;
594
+ const fields = checked.props.fields ?? defaultDataFields(entity);
595
+ const access = { ...this.service.queryAccess(entity, ANONYMOUS), anyField: true };
596
+ const rows = await this.service.delegate(entity).findMany({
597
+ where: this.publicWhere(entity, checked.props.filter),
598
+ orderBy: parseSort(entity, typeof checked.props.sort === "string" ? checked.props.sort : undefined, access),
599
+ take: typeof checked.props.limit === "number" ? checked.props.limit : 12,
600
+ include: relationInclude(entity),
601
+ });
602
+ // Only the chosen columns travel (each as the resource API returns it to an anonymous caller).
603
+ const keys = new Set(["id", ...fields]);
604
+ for (const f of entity.fields)
605
+ if (fields.includes(f.name))
606
+ keys.add(f.apiName).add(f.relation?.foreignKey ?? f.name);
607
+ const presented = await Promise.all(rows.map((r) => this.service.present(entity, r, ANONYMOUS)));
608
+ return { kind: "records", entity: entity.name, fields, rows: presented.map((r) => Object.fromEntries(Object.entries(r).filter(([k]) => keys.has(k)))) };
609
+ }
610
+ // ─── Public rendering ─────────────────────────────────────────────────────────
611
+ postSummary(row) {
612
+ const base = this.site.blog[row.kind];
613
+ return {
614
+ id: row.id,
615
+ kind: row.kind,
616
+ slug: row.slug,
617
+ title: row.title,
618
+ excerpt: row.excerpt ?? null,
619
+ coverImage: row.coverImage ?? null,
620
+ authorName: row.authorName ?? null,
621
+ publishedAt: iso(row.publishedAt),
622
+ tags: row.tags ?? [],
623
+ url: base ? `${base}/${row.slug}` : null,
624
+ };
625
+ }
626
+ postData(row) {
627
+ return { ...this.postSummary(row), body: row.body ?? "", seo: isObject(row.seo) ? row.seo : {} };
628
+ }
629
+ async sections(rows, preview) {
630
+ const visible = rows.filter((s) => !s.removed && (preview || !s.hidden)).sort((a, b) => a.position - b.position);
631
+ return Promise.all(visible.map(async (s) => {
632
+ const props = isObject(s.props) ? s.props : {};
633
+ const data = await this.sectionData(s.type, s.variant, props);
634
+ return { id: s.id, key: s.key, type: s.type, variant: s.variant, hidden: !!s.hidden, props, ...(data ? { data } : {}) };
635
+ }));
636
+ }
637
+ async pageData(row, preview) {
638
+ return {
639
+ id: row.id,
640
+ key: row.key ?? null,
641
+ path: row.path,
642
+ title: row.title,
643
+ template: row.template ?? null,
644
+ status: row.status === "draft" ? "draft" : "published",
645
+ seo: isObject(row.seo) ? row.seo : {},
646
+ sections: await this.sections(row.sections ?? [], preview),
647
+ updatedAt: iso(row.updatedAt),
648
+ };
649
+ }
650
+ chrome(pages) {
651
+ return buildSiteChrome(this.site, pages.map((p) => ({ key: p.key ?? null, path: p.path, title: p.title, status: p.status, nav: p.nav ?? null, redirect: p.redirect ?? null })), { appName: this.app.name, registration: this.app.auth.registration });
652
+ }
653
+ /** `?path=/pricing[&preview=1]` → the page, the post or the redirect at that path, with the header and footer. */
654
+ async render(query, ctx) {
655
+ const site = this.site;
656
+ const raw = typeof query.path === "string" ? query.path.trim() : "/";
657
+ if (!raw.startsWith("/") || raw.startsWith("//") || raw.length > 300)
658
+ throw new NotFoundException("Page not found");
659
+ const path = raw === "/" ? "/" : raw.replace(/\/+$/, "").toLowerCase() || "/";
660
+ const preview = (query.preview === "1" || query.preview === "true") && canEditSite(this.app, ctx);
661
+ const blog = { ...site.blog };
662
+ let pages;
663
+ try {
664
+ pages = await this.db.sitePage.findMany({ orderBy: [{ position: "asc" }, { path: "asc" }], select: { id: true, key: true, path: true, title: true, status: true, nav: true, redirect: true } });
665
+ }
666
+ catch (e) {
667
+ if (!siteTablesMissing(e))
668
+ throw e;
669
+ warnMissing();
670
+ return this.renderFromConfig(path, preview);
671
+ }
672
+ const chrome = this.chrome(pages);
673
+ const match = pages.find((p) => p.path === path);
674
+ if (match && (match.status === "published" || preview)) {
675
+ if (match.redirect)
676
+ return { kind: "redirect", redirect: match.redirect, chrome, blog };
677
+ const row = await this.db.sitePage.findUnique({ where: { id: match.id }, include: { sections: true } });
678
+ if (row)
679
+ return { kind: "page", page: await this.pageData(row, preview), chrome, blog };
680
+ }
681
+ for (const [kind, base] of Object.entries(site.blog)) {
682
+ if (!path.startsWith(`${base}/`))
683
+ continue;
684
+ const slug = path.slice(base.length + 1);
685
+ if (!SLUG.test(slug))
686
+ continue;
687
+ const post = await this.db.sitePost.findUnique({ where: { kind_slug: { kind, slug } } });
688
+ if (post && (preview || (post.status === "published" && (!post.publishedAt || post.publishedAt <= new Date()))))
689
+ return { kind: "post", post: this.postData(post), chrome, blog };
690
+ }
691
+ throw new NotFoundException("Page not found");
692
+ }
693
+ /** Until `forge db migrate` creates the tables, pages are rendered straight from the config. */
694
+ async renderFromConfig(path, preview) {
695
+ const site = this.site;
696
+ const chrome = buildSiteChrome(site, site.pages, { appName: this.app.name, registration: this.app.auth.registration });
697
+ const p = site.pages.find((x) => x.path === path && (x.status === "published" || preview));
698
+ if (!p)
699
+ throw new NotFoundException("Page not found");
700
+ if (p.redirect)
701
+ return { kind: "redirect", redirect: p.redirect, chrome, blog: { ...site.blog } };
702
+ const rows = p.sections.map((s, i) => ({ id: `config:${p.key}:${s.key}`, key: s.key, type: s.type, variant: s.variant, hidden: s.hidden, props: s.props, position: i, removed: false }));
703
+ const page = { id: `config:${p.key}`, key: p.key, path: p.path, title: p.title, template: p.template ?? null, status: p.status, seo: p.seo, sections: await this.sections(rows, preview), updatedAt: new Date(0).toISOString() };
704
+ return { kind: "page", page, chrome, blog: { ...site.blog } };
705
+ }
706
+ async publishedPosts(q) {
707
+ const where = { kind: q.kind, status: "published", publishedAt: { lte: new Date() }, ...(q.tag ? { tags: { has: q.tag } } : {}) };
708
+ const [total, rows] = await Promise.all([
709
+ this.db.sitePost.count({ where }),
710
+ this.db.sitePost.findMany({ where, orderBy: [{ publishedAt: "desc" }, { id: "asc" }], skip: (q.page - 1) * q.limit, take: q.limit, omit: { body: true } }),
711
+ ]);
712
+ return { data: rows.map((r) => this.postSummary(r)), total };
713
+ }
714
+ /** `?kind=post&limit=20&page=1&tag=x`: published posts, newest first. */
715
+ async posts(query) {
716
+ void this.site;
717
+ const kind = (query.kind ?? "post");
718
+ if (!POST_KINDS.includes(kind))
719
+ throw invalid([{ path: "kind", message: `Must be one of ${POST_KINDS.join(", ")}` }]);
720
+ const limit = Math.min(50, Math.max(1, Number(query.limit) || 20));
721
+ const page = Math.max(1, Math.floor(Number(query.page)) || 1);
722
+ const tag = typeof query.tag === "string" && query.tag.trim() ? query.tag.trim().slice(0, 40) : undefined;
723
+ try {
724
+ return await this.publishedPosts({ kind: kind, limit, page, ...(tag ? { tag } : {}) });
725
+ }
726
+ catch (e) {
727
+ if (!siteTablesMissing(e))
728
+ throw e;
729
+ warnMissing();
730
+ return { data: [], total: 0 };
731
+ }
732
+ }
733
+ async sitemap() {
734
+ const site = this.site;
735
+ try {
736
+ const pages = await this.db.sitePage.findMany({ where: { status: "published", redirect: null }, orderBy: [{ position: "asc" }, { path: "asc" }], select: { path: true, updatedAt: true } });
737
+ const kinds = Object.keys(site.blog);
738
+ const posts = kinds.length
739
+ ? await this.db.sitePost.findMany({ where: { kind: { in: kinds }, status: "published", publishedAt: { lte: new Date() } }, orderBy: { publishedAt: "desc" }, take: 5000, select: { kind: true, slug: true, updatedAt: true } })
740
+ : [];
741
+ return {
742
+ pages: pages.map((p) => ({ path: p.path, updatedAt: iso(p.updatedAt) })),
743
+ posts: posts.map((p) => ({ path: `${site.blog[p.kind]}/${p.slug}`, updatedAt: iso(p.updatedAt) })),
744
+ };
745
+ }
746
+ catch (e) {
747
+ if (!siteTablesMissing(e))
748
+ throw e;
749
+ warnMissing();
750
+ return { pages: site.pages.filter((p) => p.status === "published" && !p.redirect).map((p) => ({ path: p.path, updatedAt: new Date(0).toISOString() })), posts: [] };
751
+ }
752
+ }
753
+ // ─── Forms ────────────────────────────────────────────────────────────────────
754
+ /** Users whose global roles make them site editors (contact form notifications). */
755
+ async editorIds() {
756
+ const editors = this.site.editors.length ? this.site.editors : ["admin"];
757
+ const users = await this.prisma.user.findMany({ where: { OR: editors.map((r) => ({ role: { contains: r } })) }, select: { id: true, role: true, banned: true }, take: 500 });
758
+ return users.filter((u) => !u.banned && String(u.role ?? "").split(",").map((r) => r.trim()).some((r) => editors.includes(r))).map((u) => u.id);
759
+ }
760
+ async contact(body, meta) {
761
+ void this.site;
762
+ const b = isObject(body) ? body : {};
763
+ const { issues, value } = validateContact(b);
764
+ if (issues.length)
765
+ throw invalid(issues);
766
+ // Bots get the same answer as people, but nothing is stored.
767
+ if (looksLikeSpam(b))
768
+ return { ok: true };
769
+ if (rateLimited(`contact:${meta.ip ?? "unknown"}`, 5))
770
+ throw tooMany();
771
+ return this.guard(async () => {
772
+ const row = await this.db.contactMessage.create({ data: { ...value, ip: meta.ip ?? null, userAgent: meta.userAgent?.slice(0, 500) ?? null } });
773
+ const excerpt = row.message.length > 280 ? `${row.message.slice(0, 279).trimEnd()}…` : row.message;
774
+ await this.service.notifications
775
+ .notify(await this.editorIds(), {
776
+ title: `New message from ${row.name}`,
777
+ body: `${row.email}${row.company ? ` (${row.company})` : ""}: ${excerpt}`,
778
+ link: "/admin/content?tab=messages",
779
+ event: "site.contact",
780
+ organizationId: null,
781
+ // Emailed too when the app has a mail service.
782
+ email: true,
783
+ })
784
+ .catch((e) => logger.warn(`Contact notification failed: ${e.message}`));
785
+ return { ok: true };
786
+ });
787
+ }
788
+ async newsletter(body, meta) {
789
+ void this.site;
790
+ const b = isObject(body) ? body : {};
791
+ const { issues, value } = validateNewsletter(b);
792
+ if (issues.length)
793
+ throw invalid(issues);
794
+ if (looksLikeSpam(b))
795
+ return { ok: true };
796
+ if (rateLimited(`newsletter:${meta.ip ?? "unknown"}`, 10))
797
+ throw tooMany();
798
+ return this.guard(async () => {
799
+ try {
800
+ // Same answer whether the address was known or not.
801
+ await this.db.newsletterSubscriber.upsert({ where: { email: value.email }, create: { email: value.email, source: value.source ?? null, ip: meta.ip ?? null }, update: { status: "subscribed" } });
802
+ }
803
+ catch (e) {
804
+ if (e.code !== "P2002")
805
+ throw e; // two concurrent sign-ups of the same address
806
+ }
807
+ return { ok: true };
808
+ });
809
+ }
810
+ // ─── Admin: pages ─────────────────────────────────────────────────────────────
811
+ listItem(p) {
812
+ const sections = p.sections ?? [];
813
+ return {
814
+ id: p.id,
815
+ key: p.key ?? null,
816
+ path: p.path,
817
+ title: p.title,
818
+ template: p.template ?? null,
819
+ status: p.status === "draft" ? "draft" : "published",
820
+ origin: p.origin === "admin" ? "admin" : "config",
821
+ edited: p.edited,
822
+ conflict: p.conflict,
823
+ sectionConflicts: sections.filter((s) => s.conflict).length,
824
+ sections: sections.filter((s) => !s.removed).length,
825
+ redirect: p.redirect ?? null,
826
+ updatedAt: iso(p.updatedAt),
827
+ };
828
+ }
829
+ async overview(ctx) {
830
+ this.editor(ctx);
831
+ return this.guard(async () => {
832
+ const [pages, messages, subscribers] = await Promise.all([
833
+ this.db.sitePage.findMany({ orderBy: [{ position: "asc" }, { path: "asc" }], include: { sections: { select: { conflict: true, removed: true } } } }),
834
+ this.db.contactMessage.count({ where: { status: "new" } }),
835
+ this.db.newsletterSubscriber.count({ where: { status: "subscribed" } }),
836
+ ]);
837
+ const items = pages.map((p) => this.listItem(p));
838
+ return {
839
+ pages: items,
840
+ conflicts: items.reduce((n, p) => n + (p.conflict ? 1 : 0) + p.sectionConflicts, 0),
841
+ messages: { new: messages },
842
+ subscribers: subscribers,
843
+ storage: this.app.plugins.some((p) => p.name === "storage"),
844
+ payments: this.app.plugins.some((p) => p.name === "payments"),
845
+ blogKinds: { ...this.site.blog },
846
+ };
847
+ });
848
+ }
849
+ async findPage(id) {
850
+ const row = UUID.test(id) ? await this.db.sitePage.findUnique({ where: { id }, include: { sections: { orderBy: { position: "asc" } } } }) : null;
851
+ if (!row)
852
+ throw new NotFoundException("Page not found");
853
+ return row;
854
+ }
855
+ async pathTaken(path, exceptId) {
856
+ return !!(await this.db.sitePage.findFirst({ where: { path, ...(exceptId ? { id: { not: exceptId } } : {}) }, select: { id: true } }));
857
+ }
858
+ async detail(row) {
859
+ const config = this.configPage(row.origin === "config" ? row.key : null);
860
+ const sections = await Promise.all(row.sections.map(async (s) => {
861
+ const props = isObject(s.props) ? s.props : {};
862
+ const ir = s.origin === "config" ? config?.sections.find((x) => x.key === s.key) : undefined;
863
+ const data = s.removed ? undefined : await this.sectionData(s.type, s.variant, props);
864
+ return {
865
+ id: s.id,
866
+ key: s.key,
867
+ type: s.type,
868
+ variant: s.variant,
869
+ hidden: s.hidden,
870
+ props,
871
+ ...(data ? { data } : {}),
872
+ origin: s.origin === "admin" ? "admin" : "config",
873
+ edited: s.edited,
874
+ conflict: s.conflict,
875
+ removed: s.removed,
876
+ position: s.position,
877
+ config: ir ? { variant: ir.variant, hidden: ir.hidden, props: ir.props } : null,
878
+ };
879
+ }));
880
+ return {
881
+ id: row.id,
882
+ key: row.key ?? null,
883
+ path: row.path,
884
+ title: row.title,
885
+ template: row.template ?? null,
886
+ status: row.status === "draft" ? "draft" : "published",
887
+ seo: isObject(row.seo) ? row.seo : {},
888
+ updatedAt: iso(row.updatedAt),
889
+ origin: row.origin === "admin" ? "admin" : "config",
890
+ edited: row.edited,
891
+ conflict: row.conflict,
892
+ nav: row.nav === false || isObject(row.nav) ? row.nav : null,
893
+ redirect: row.redirect ?? null,
894
+ config: config ? { title: config.title, path: config.path, seo: config.seo, status: config.status, nav: config.nav, redirect: config.redirect ?? null } : null,
895
+ sections,
896
+ };
897
+ }
898
+ async page(id, ctx) {
899
+ this.editor(ctx);
900
+ return this.guard(async () => this.detail(await this.findPage(id)));
901
+ }
902
+ /** Validated page fields (title, path, seo, nav, status, redirect); only the keys present in `body`. */
903
+ pageFields(body, issues) {
904
+ const out = {};
905
+ for (const k of Object.keys(body))
906
+ if (!["title", "path", "seo", "nav", "status", "redirect", "template"].includes(k))
907
+ issues.push({ path: k, message: `Unknown key "${k}"` });
908
+ if (body.title !== undefined) {
909
+ const t = text(body.title);
910
+ typeof t === "string" && t && t.length <= 120 ? (out.title = t) : issues.push({ path: "title", message: "Must be a text of 1 to 120 characters" });
911
+ }
912
+ if (body.path !== undefined) {
913
+ const problem = sitePathProblem(body.path, this.takenPaths());
914
+ problem ? issues.push({ path: "path", message: problem }) : (out.path = body.path);
915
+ }
916
+ const seo = seoInput(body.seo, "seo", issues);
917
+ if (seo)
918
+ out.seo = seo;
919
+ if (body.nav !== undefined) {
920
+ const nav = body.nav;
921
+ if (nav === null || nav === false)
922
+ out.nav = nav;
923
+ else if (!isObject(nav))
924
+ issues.push({ path: "nav", message: "Must be false, null or { group?, label?, footerOnly? }" });
925
+ else {
926
+ const n = {};
927
+ for (const k of Object.keys(nav))
928
+ if (!["group", "label", "footerOnly"].includes(k))
929
+ issues.push({ path: `nav.${k}`, message: "Unknown key (nav has group, label, footerOnly)" });
930
+ for (const k of ["group", "label"]) {
931
+ const v = text(nav[k]);
932
+ if (v === undefined || v === null || v === "")
933
+ continue;
934
+ typeof v === "string" && v.length <= 40 ? (n[k] = v) : issues.push({ path: `nav.${k}`, message: "Must be a short text (at most 40 characters)" });
935
+ }
936
+ if (nav.footerOnly !== undefined)
937
+ typeof nav.footerOnly === "boolean" ? nav.footerOnly && (n.footerOnly = true) : issues.push({ path: "nav.footerOnly", message: "Must be true or false" });
938
+ out.nav = n;
939
+ }
940
+ }
941
+ if (body.status !== undefined)
942
+ body.status === "published" || body.status === "draft" ? (out.status = body.status) : issues.push({ path: "status", message: 'Must be "published" or "draft"' });
943
+ if (body.redirect !== undefined) {
944
+ if (body.redirect === null || body.redirect === "")
945
+ out.redirect = null;
946
+ else
947
+ validHref(body.redirect) ? (out.redirect = body.redirect) : issues.push({ path: "redirect", message: 'Must be a path such as "/sign-in" or an http(s) URL' });
948
+ }
949
+ return out;
950
+ }
951
+ /** New admin page, optionally from a template (sections with placeholder copy). Starts as a draft. */
952
+ async createPage(body, ctx) {
953
+ this.editor(ctx);
954
+ const b = isObject(body) ? body : {};
955
+ const issues = [];
956
+ if (b.title === undefined)
957
+ issues.push({ path: "title", message: "Required" });
958
+ if (b.path === undefined)
959
+ issues.push({ path: "path", message: "Required" });
960
+ const { template, status, ...rest } = b;
961
+ const fields = this.pageFields({ ...rest, ...(status !== undefined ? { status } : {}) }, issues);
962
+ if (template !== undefined && template !== null && (typeof template !== "string" || !PAGE_TEMPLATE_NAMES.includes(template)))
963
+ issues.push({ path: "template", message: `Unknown template (templates: ${PAGE_TEMPLATE_NAMES.join(", ")})` });
964
+ if (issues.length)
965
+ throw invalid(issues);
966
+ return this.guard(async () => {
967
+ if (await this.pathTaken(fields.path))
968
+ throw new ConflictException(`Another page already uses "${fields.path}"`);
969
+ const base = typeof template === "string" ? templatePage(template, this.templateCtx()) : undefined;
970
+ const keys = new Set();
971
+ const sections = (base?.sections ?? []).map((s, i) => {
972
+ const checked = checkSiteSection(this.checkApp(), { type: s.type, variant: s.variant, props: s.props });
973
+ let key = s.id ?? s.type;
974
+ for (let n = 2; keys.has(key); n++)
975
+ key = `${s.id ?? s.type}-${n}`;
976
+ keys.add(key);
977
+ return { key, type: checked.type, variant: checked.variant, props: checked.props, origin: "admin", position: i };
978
+ });
979
+ const position = ((await this.db.sitePage.aggregate({ _max: { position: true } }))._max.position ?? -1) + 1;
980
+ const statusValue = fields.status ?? "draft";
981
+ const { nav, ...rest2 } = fields;
982
+ const navValue = nav !== undefined ? nav : base?.nav;
983
+ const row = await this.db.sitePage.create({
984
+ data: {
985
+ ...rest2,
986
+ ...(navValue !== undefined && navValue !== null ? { nav: navValue } : {}),
987
+ seo: fields.seo ?? base?.seo ?? {},
988
+ ...(typeof template === "string" ? { template } : {}),
989
+ ...(fields.redirect === undefined && base?.redirect ? { redirect: base.redirect } : {}),
990
+ status: statusValue,
991
+ publishedAt: statusValue === "published" ? new Date() : null,
992
+ origin: "admin",
993
+ orderEdited: true,
994
+ position,
995
+ sections: { create: sections },
996
+ },
997
+ });
998
+ return this.detail(await this.findPage(row.id));
999
+ });
1000
+ }
1001
+ async updatePage(id, body, ctx) {
1002
+ this.editor(ctx);
1003
+ const b = isObject(body) ? body : {};
1004
+ const issues = [];
1005
+ if ("template" in b)
1006
+ issues.push({ path: "template", message: "The template of a page can't be changed" });
1007
+ const fields = this.pageFields(b, issues);
1008
+ if (issues.length)
1009
+ throw invalid(issues);
1010
+ return this.guard(async () => {
1011
+ const row = await this.findPage(id);
1012
+ if (fields.path !== undefined && fields.path !== row.path && (await this.pathTaken(fields.path, id)))
1013
+ throw new ConflictException(`Another page already uses "${fields.path}"`);
1014
+ const data = { ...fields };
1015
+ if (fields.status === "published" && !row.publishedAt)
1016
+ data.publishedAt = new Date();
1017
+ if (row.origin === "config") {
1018
+ // Back to exactly the config version = no longer edited.
1019
+ const config = this.configPage(row.key);
1020
+ const merged = { title: row.title, path: row.path, seo: row.seo ?? {}, status: row.status, nav: row.nav ?? null, redirect: row.redirect ?? null, ...fields };
1021
+ const own = { title: merged.title, path: merged.path, seo: merged.seo, status: merged.status, nav: merged.nav, ...(merged.redirect ? { redirect: merged.redirect } : {}) };
1022
+ if (config && contentHash(own) === config.hash)
1023
+ Object.assign(data, { edited: false, conflict: false, configHash: config.hash });
1024
+ else if (Object.keys(fields).length)
1025
+ data.edited = true;
1026
+ }
1027
+ const { nav, ...rest } = data;
1028
+ await this.db.sitePage.update({ where: { id }, data: { ...rest, ...(nav !== undefined && nav !== null ? { nav } : {}) } });
1029
+ await writeNav(this.db, id, nav);
1030
+ return this.detail(await this.findPage(id));
1031
+ });
1032
+ }
1033
+ async deletePage(id, ctx) {
1034
+ this.editor(ctx);
1035
+ return this.guard(async () => {
1036
+ const row = await this.findPage(id);
1037
+ if (row.origin === "config")
1038
+ throw new ConflictException(`This page comes from forge.config.ts — remove it there (forge remove page ${row.key}) or unpublish it`);
1039
+ await this.db.sitePage.delete({ where: { id } });
1040
+ });
1041
+ }
1042
+ async revertPage(id, ctx) {
1043
+ this.editor(ctx);
1044
+ return this.guard(async () => {
1045
+ const row = await this.findPage(id);
1046
+ const config = row.origin === "config" ? this.configPage(row.key) : undefined;
1047
+ if (!config)
1048
+ throw new ConflictException("This page was created in the admin — there is no config version to go back to");
1049
+ if (config.path !== row.path && (await this.pathTaken(config.path, id)))
1050
+ throw new ConflictException(`Another page already uses "${config.path}" — change that page's path first`);
1051
+ const { nav, ...fields } = configPageFields(config);
1052
+ await this.db.sitePage.update({
1053
+ where: { id },
1054
+ data: { ...fields, ...(nav !== null ? { nav } : {}), edited: false, conflict: false, configHash: config.hash, ...(config.status === "published" && !row.publishedAt ? { publishedAt: new Date() } : {}) },
1055
+ });
1056
+ await writeNav(this.db, id, nav);
1057
+ return this.detail(await this.findPage(id));
1058
+ });
1059
+ }
1060
+ // ─── Admin: sections ──────────────────────────────────────────────────────────
1061
+ checkSection(input) {
1062
+ const checked = checkSiteSection(this.checkApp(), input, "section");
1063
+ if (checked.issues.length)
1064
+ throw invalid(checked.issues.map((i) => ({ path: i.path.replace(/^section\.?/, "") || "section", message: i.message })));
1065
+ return checked;
1066
+ }
1067
+ async findSection(id) {
1068
+ const row = UUID.test(id) ? await this.db.siteSection.findUnique({ where: { id }, include: { page: { select: { id: true, key: true, origin: true } } } }) : null;
1069
+ if (!row)
1070
+ throw new NotFoundException("Section not found");
1071
+ return row;
1072
+ }
1073
+ configSection(row) {
1074
+ if (row.origin !== "config" || row.page.origin !== "config")
1075
+ return undefined;
1076
+ return this.configPage(row.page.key)?.sections.find((s) => s.key === row.key);
1077
+ }
1078
+ /** `after`: index (among the visible order) of the section the new one follows; -1 = first; default: last. */
1079
+ async addSection(pageId, body, ctx) {
1080
+ this.editor(ctx);
1081
+ const b = isObject(body) ? body : {};
1082
+ if (b.props !== undefined && !isObject(b.props))
1083
+ throw invalid([{ path: "props", message: "Must be an object" }]);
1084
+ if (b.after !== undefined && (typeof b.after !== "number" || !Number.isInteger(b.after)))
1085
+ throw invalid([{ path: "after", message: "Must be a section index" }]);
1086
+ const defaults = typeof b.type === "string" ? sectionDefaults(b.type, this.templateCtx()) : {};
1087
+ const checked = this.checkSection({ type: b.type, ...(b.variant !== undefined ? { variant: b.variant } : {}), props: { ...defaults, ...(b.props ?? {}) } });
1088
+ return this.guard(async () => {
1089
+ const page = await this.findPage(pageId);
1090
+ const live = page.sections.filter((s) => !s.removed);
1091
+ if (live.length >= MAX_PAGE_SECTIONS)
1092
+ throw invalid([{ path: "sections", message: `At most ${MAX_PAGE_SECTIONS} sections per page` }]);
1093
+ const at = b.after === undefined ? live.length : Math.min(live.length, Math.max(0, b.after + 1));
1094
+ const key = `${checked.type}-${randomBytes(3).toString("hex")}`;
1095
+ const order = [...live.slice(0, at).map((s) => s.id), null, ...live.slice(at).map((s) => s.id)];
1096
+ await this.db.$transaction(async (tx) => {
1097
+ await tx.siteSection.create({ data: { pageId, key, type: checked.type, variant: checked.variant, props: checked.props, origin: "admin", position: at } });
1098
+ for (const [i, sid] of order.entries())
1099
+ if (sid)
1100
+ await tx.siteSection.update({ where: { id: sid }, data: { position: i } });
1101
+ await tx.sitePage.update({ where: { id: pageId }, data: { orderEdited: true } });
1102
+ });
1103
+ return this.detail(await this.findPage(pageId));
1104
+ });
1105
+ }
1106
+ async reorder(pageId, body, ctx) {
1107
+ this.editor(ctx);
1108
+ const ids = isObject(body) && Array.isArray(body.ids) ? body.ids : undefined;
1109
+ if (!ids || ids.some((x) => typeof x !== "string"))
1110
+ throw invalid([{ path: "ids", message: "Must be the list of section ids in the new order" }]);
1111
+ return this.guard(async () => {
1112
+ const page = await this.findPage(pageId);
1113
+ const live = page.sections.filter((s) => !s.removed).map((s) => s.id);
1114
+ if (ids.length !== live.length || new Set(ids).size !== ids.length || !ids.every((x) => live.includes(x))) {
1115
+ throw invalid([{ path: "ids", message: "Must list every section of the page exactly once" }]);
1116
+ }
1117
+ await this.db.$transaction(async (tx) => {
1118
+ for (const [i, sid] of ids.entries())
1119
+ await tx.siteSection.update({ where: { id: sid }, data: { position: i } });
1120
+ await tx.sitePage.update({ where: { id: pageId }, data: { orderEdited: true } });
1121
+ });
1122
+ return this.detail(await this.findPage(pageId));
1123
+ });
1124
+ }
1125
+ async updateSection(id, body, ctx) {
1126
+ this.editor(ctx);
1127
+ const b = isObject(body) ? body : {};
1128
+ for (const k of Object.keys(b))
1129
+ if (!["variant", "props", "hidden"].includes(k))
1130
+ throw invalid([{ path: k, message: `Unknown key "${k}" (variant, props, hidden)` }]);
1131
+ if (b.hidden !== undefined && typeof b.hidden !== "boolean")
1132
+ throw invalid([{ path: "hidden", message: "Must be true or false" }]);
1133
+ return this.guard(async () => {
1134
+ const row = await this.findSection(id);
1135
+ if (row.removed)
1136
+ throw new ConflictException("This section was deleted — restore it first");
1137
+ const checked = this.checkSection({ type: row.type, variant: b.variant ?? row.variant, props: b.props !== undefined ? b.props : row.props });
1138
+ const hidden = b.hidden ?? row.hidden;
1139
+ const data = { variant: checked.variant, props: checked.props, hidden };
1140
+ if (row.origin === "config") {
1141
+ // Back to exactly the config version = no longer edited.
1142
+ const ir = this.configSection(row);
1143
+ if (ir && contentHash({ variant: checked.variant, hidden, props: checked.props }) === ir.hash)
1144
+ Object.assign(data, { edited: false, conflict: false, configHash: ir.hash });
1145
+ else
1146
+ data.edited = true;
1147
+ }
1148
+ await this.db.siteSection.update({ where: { id }, data });
1149
+ return this.sectionDetail(id);
1150
+ });
1151
+ }
1152
+ async sectionDetail(id) {
1153
+ const row = await this.findSection(id);
1154
+ const detail = await this.detail({ ...(await this.findPage(row.pageId)) });
1155
+ return detail.sections.find((s) => s.id === id);
1156
+ }
1157
+ /** Config sections are only marked removed (so the next seed doesn't bring them back); admin sections are deleted. */
1158
+ async deleteSection(id, ctx) {
1159
+ this.editor(ctx);
1160
+ return this.guard(async () => {
1161
+ const row = await this.findSection(id);
1162
+ if (row.origin === "config")
1163
+ await this.db.siteSection.update({ where: { id }, data: { removed: true } });
1164
+ else
1165
+ await this.db.siteSection.delete({ where: { id } });
1166
+ });
1167
+ }
1168
+ async revertSection(id, ctx) {
1169
+ this.editor(ctx);
1170
+ return this.guard(async () => {
1171
+ const row = await this.findSection(id);
1172
+ const ir = this.configSection(row);
1173
+ if (!ir)
1174
+ throw new ConflictException("This section was added in the admin — there is no config version to go back to");
1175
+ await this.db.siteSection.update({ where: { id }, data: { type: ir.type, variant: ir.variant, hidden: ir.hidden, props: ir.props, edited: false, conflict: false, removed: false, configHash: ir.hash } });
1176
+ return this.sectionDetail(id);
1177
+ });
1178
+ }
1179
+ /** Live data for unsaved sections of the editor's preview; an entry is null when the section is invalid or has no data. */
1180
+ async preview(body, ctx) {
1181
+ this.editor(ctx);
1182
+ const list = isObject(body) && Array.isArray(body.sections) ? body.sections : undefined;
1183
+ if (!list)
1184
+ throw invalid([{ path: "sections", message: "Must be a list of { type, variant, props }" }]);
1185
+ if (list.length > MAX_PAGE_SECTIONS)
1186
+ throw invalid([{ path: "sections", message: `At most ${MAX_PAGE_SECTIONS} sections` }]);
1187
+ const data = await Promise.all(list.map(async (s) => {
1188
+ if (!isObject(s))
1189
+ return null;
1190
+ const checked = checkSiteSection(this.checkApp(), { type: s.type, variant: s.variant, props: s.props }, "section");
1191
+ if (checked.issues.length)
1192
+ return null;
1193
+ return (await this.sectionData(checked.type, checked.variant, checked.props)) ?? null;
1194
+ }));
1195
+ return { data };
1196
+ }
1197
+ // ─── Admin: posts ─────────────────────────────────────────────────────────────
1198
+ postAdmin(row) {
1199
+ return { ...this.postData(row), status: row.status === "published" ? "published" : "draft", authorId: row.authorId ?? null, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt) };
1200
+ }
1201
+ postFields(b, issues, create) {
1202
+ const out = {};
1203
+ for (const k of Object.keys(b))
1204
+ if (!["kind", "title", "slug", "excerpt", "coverImage", "body", "tags", "status", "publishedAt", "authorName", "seo"].includes(k))
1205
+ issues.push({ path: k, message: `Unknown key "${k}"` });
1206
+ if (b.kind !== undefined || create) {
1207
+ const kind = b.kind ?? "post";
1208
+ POST_KINDS.includes(kind) ? (out.kind = kind) : issues.push({ path: "kind", message: `Must be one of ${POST_KINDS.join(", ")}` });
1209
+ }
1210
+ if (b.title !== undefined || create) {
1211
+ const t = text(b.title);
1212
+ typeof t === "string" && t.length >= 1 && t.length <= 200 ? (out.title = t) : issues.push({ path: "title", message: t === undefined || t === "" ? "Required" : "Must be a text of 1 to 200 characters" });
1213
+ }
1214
+ if (b.slug !== undefined && b.slug !== null && b.slug !== "")
1215
+ typeof b.slug === "string" && b.slug.length <= 100 && SLUG.test(b.slug) ? (out.slug = b.slug) : issues.push({ path: "slug", message: 'Must be lowercase words separated by dashes, such as "my-first-post" (at most 100 characters)' });
1216
+ const optional = (key, max) => {
1217
+ if (b[key] === undefined)
1218
+ return;
1219
+ const v = text(b[key]);
1220
+ if (v === null || v === "")
1221
+ out[key] = null;
1222
+ else
1223
+ typeof v === "string" && v.length <= max ? (out[key] = v) : issues.push({ path: key, message: `Must be at most ${max} characters` });
1224
+ };
1225
+ optional("excerpt", 500);
1226
+ optional("authorName", 100);
1227
+ if (b.body !== undefined)
1228
+ typeof b.body === "string" && b.body.length <= 100_000 ? (out.body = b.body) : issues.push({ path: "body", message: "Must be markdown text of at most 100000 characters" });
1229
+ if (b.coverImage !== undefined) {
1230
+ if (b.coverImage === null || b.coverImage === "")
1231
+ out.coverImage = null;
1232
+ else if (!validImage(b.coverImage))
1233
+ issues.push({ path: "coverImage", message: 'Must be an image URL (https://…) or a path such as "/cover.png"' });
1234
+ else if (isUploadedImage(b.coverImage) && !this.app.plugins.some((p) => p.name === "storage"))
1235
+ issues.push({ path: "coverImage", message: "Uploaded images need the storage plugin (forge add storage)" });
1236
+ else
1237
+ out.coverImage = b.coverImage;
1238
+ }
1239
+ if (b.tags !== undefined) {
1240
+ const tags = Array.isArray(b.tags) ? b.tags.map(text) : null;
1241
+ if (!tags || tags.length > 10 || tags.some((t) => typeof t !== "string" || !t || t.length > 40))
1242
+ issues.push({ path: "tags", message: "At most 10 tags of at most 40 characters" });
1243
+ else
1244
+ out.tags = [...new Set(tags)];
1245
+ }
1246
+ if (b.status !== undefined)
1247
+ b.status === "draft" || b.status === "published" ? (out.status = b.status) : issues.push({ path: "status", message: 'Must be "draft" or "published"' });
1248
+ if (b.publishedAt !== undefined) {
1249
+ if (b.publishedAt === null || b.publishedAt === "")
1250
+ out.publishedAt = null;
1251
+ else {
1252
+ const d = typeof b.publishedAt === "string" ? new Date(b.publishedAt) : null;
1253
+ d && !Number.isNaN(d.getTime()) ? (out.publishedAt = d) : issues.push({ path: "publishedAt", message: "Must be a date" });
1254
+ }
1255
+ }
1256
+ const seo = seoInput(b.seo, "seo", issues);
1257
+ if (seo)
1258
+ out.seo = seo;
1259
+ return out;
1260
+ }
1261
+ async findPost(id) {
1262
+ const row = UUID.test(id) ? await this.db.sitePost.findUnique({ where: { id } }) : null;
1263
+ if (!row)
1264
+ throw new NotFoundException("Post not found");
1265
+ return row;
1266
+ }
1267
+ async listPosts(query, ctx) {
1268
+ this.editor(ctx);
1269
+ const where = [];
1270
+ if (typeof query.kind === "string" && query.kind)
1271
+ where.push({ kind: query.kind });
1272
+ if (query.status === "draft" || query.status === "published")
1273
+ where.push({ status: query.status });
1274
+ const search = typeof query.search === "string" ? query.search.trim().slice(0, 100) : "";
1275
+ if (search)
1276
+ where.push({ OR: [{ title: { contains: search, mode: "insensitive" } }, { slug: { contains: search.toLowerCase() } }] });
1277
+ const pageSize = Math.min(100, Math.max(1, Number(query.pageSize) || 20));
1278
+ const page = Math.max(1, Math.floor(Number(query.page)) || 1);
1279
+ return this.guard(async () => {
1280
+ const [total, rows] = await Promise.all([
1281
+ this.db.sitePost.count({ where: { AND: where } }),
1282
+ this.db.sitePost.findMany({ where: { AND: where }, orderBy: [{ updatedAt: "desc" }, { id: "asc" }], skip: (page - 1) * pageSize, take: pageSize }),
1283
+ ]);
1284
+ return { data: rows.map((r) => this.postAdmin(r)), total };
1285
+ });
1286
+ }
1287
+ async getPost(id, ctx) {
1288
+ this.editor(ctx);
1289
+ return this.guard(async () => this.postAdmin(await this.findPost(id)));
1290
+ }
1291
+ async createPost(body, ctx) {
1292
+ this.editor(ctx);
1293
+ const issues = [];
1294
+ const data = this.postFields(isObject(body) ? body : {}, issues, true);
1295
+ if (issues.length)
1296
+ throw invalid(issues);
1297
+ return this.guard(async () => {
1298
+ if (data.slug) {
1299
+ if (await this.db.sitePost.findUnique({ where: { kind_slug: { kind: data.kind, slug: data.slug } } }))
1300
+ throw new ConflictException(`Another ${data.kind} already uses the slug "${data.slug}"`);
1301
+ }
1302
+ else {
1303
+ // Unique per kind: "my-post", "my-post-2"…
1304
+ const base = slugify(data.title);
1305
+ const taken = new Set((await this.db.sitePost.findMany({ where: { kind: data.kind, slug: { startsWith: base } }, select: { slug: true } })).map((r) => r.slug));
1306
+ let slug = base;
1307
+ for (let n = 2; taken.has(slug); n++)
1308
+ slug = `${base.slice(0, 95)}-${n}`;
1309
+ data.slug = slug;
1310
+ }
1311
+ if (data.status === "published" && !data.publishedAt)
1312
+ data.publishedAt = new Date();
1313
+ const row = await this.db.sitePost.create({ data: { ...data, authorId: ctx.principal.id, authorName: data.authorName ?? ctx.user?.name ?? null } });
1314
+ return this.postAdmin(row);
1315
+ });
1316
+ }
1317
+ async updatePost(id, body, ctx) {
1318
+ this.editor(ctx);
1319
+ const issues = [];
1320
+ const data = this.postFields(isObject(body) ? body : {}, issues, false);
1321
+ if (issues.length)
1322
+ throw invalid(issues);
1323
+ return this.guard(async () => {
1324
+ const row = await this.findPost(id);
1325
+ const kind = data.kind ?? row.kind;
1326
+ const slug = data.slug ?? row.slug;
1327
+ if ((kind !== row.kind || slug !== row.slug) && (await this.db.sitePost.findFirst({ where: { kind, slug, id: { not: id } } })))
1328
+ throw new ConflictException(`Another ${kind} already uses the slug "${slug}"`);
1329
+ // Publishing without a date publishes now.
1330
+ const publishedAt = "publishedAt" in data ? data.publishedAt : row.publishedAt;
1331
+ if ((data.status ?? row.status) === "published" && !publishedAt)
1332
+ data.publishedAt = new Date();
1333
+ return this.postAdmin(await this.db.sitePost.update({ where: { id }, data }));
1334
+ });
1335
+ }
1336
+ async deletePost(id, ctx) {
1337
+ this.editor(ctx);
1338
+ return this.guard(async () => {
1339
+ await this.findPost(id);
1340
+ await this.db.sitePost.delete({ where: { id } });
1341
+ });
1342
+ }
1343
+ // ─── Admin: messages and subscribers ─────────────────────────────────────────
1344
+ message(r) {
1345
+ return { id: r.id, name: r.name, email: r.email, company: r.company ?? null, message: r.message, page: r.page ?? null, status: r.status, createdAt: iso(r.createdAt) };
1346
+ }
1347
+ async messages(query, ctx) {
1348
+ this.editor(ctx);
1349
+ const where = ["new", "read", "archived"].includes(query.status) ? { status: query.status } : {};
1350
+ const page = Math.max(1, Math.floor(Number(query.page)) || 1);
1351
+ const pageSize = Math.min(100, Math.max(1, Number(query.pageSize) || 20));
1352
+ return this.guard(async () => {
1353
+ const [total, rows] = await Promise.all([this.db.contactMessage.count({ where }), this.db.contactMessage.findMany({ where, orderBy: [{ createdAt: "desc" }, { id: "asc" }], skip: (page - 1) * pageSize, take: pageSize })]);
1354
+ return { data: rows.map((r) => this.message(r)), total: total };
1355
+ });
1356
+ }
1357
+ async updateMessage(id, body, ctx) {
1358
+ this.editor(ctx);
1359
+ const status = isObject(body) ? body.status : undefined;
1360
+ if (!["new", "read", "archived"].includes(status))
1361
+ throw invalid([{ path: "status", message: 'Must be "new", "read" or "archived"' }]);
1362
+ return this.guard(async () => {
1363
+ const row = UUID.test(id) ? await this.db.contactMessage.findUnique({ where: { id } }) : null;
1364
+ if (!row)
1365
+ throw new NotFoundException("Message not found");
1366
+ return this.message(await this.db.contactMessage.update({ where: { id }, data: { status } }));
1367
+ });
1368
+ }
1369
+ async deleteMessage(id, ctx) {
1370
+ this.editor(ctx);
1371
+ return this.guard(async () => {
1372
+ const { count } = UUID.test(id) ? await this.db.contactMessage.deleteMany({ where: { id } }) : { count: 0 };
1373
+ if (!count)
1374
+ throw new NotFoundException("Message not found");
1375
+ });
1376
+ }
1377
+ subscriber(r) {
1378
+ return { id: r.id, email: r.email, status: r.status, source: r.source ?? null, createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt) };
1379
+ }
1380
+ async subscribers(query, ctx) {
1381
+ this.editor(ctx);
1382
+ const page = Math.max(1, Math.floor(Number(query.page)) || 1);
1383
+ const pageSize = Math.min(100, Math.max(1, Number(query.pageSize) || 20));
1384
+ const where = query.status === "subscribed" || query.status === "unsubscribed" ? { status: query.status } : {};
1385
+ return this.guard(async () => {
1386
+ const [total, rows] = await Promise.all([this.db.newsletterSubscriber.count({ where }), this.db.newsletterSubscriber.findMany({ where, orderBy: [{ createdAt: "desc" }, { id: "asc" }], skip: (page - 1) * pageSize, take: pageSize })]);
1387
+ return { data: rows.map((r) => this.subscriber(r)), total: total };
1388
+ });
1389
+ }
1390
+ /** Subscribed addresses as CSV (cells that a spreadsheet would run as formulas are neutralized). */
1391
+ async exportSubscribers(ctx) {
1392
+ this.editor(ctx);
1393
+ return this.guard(async () => {
1394
+ const rows = await this.db.newsletterSubscriber.findMany({ where: { status: "subscribed" }, orderBy: { createdAt: "asc" }, take: 100_000 });
1395
+ const cell = (v) => {
1396
+ let s = v === null || v === undefined ? "" : String(v);
1397
+ if (/^[=+\-@\t\r]/.test(s))
1398
+ s = `'${s}`;
1399
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
1400
+ };
1401
+ return ["email,status,source,subscribed_at", ...rows.map((r) => [r.email, r.status, r.source, iso(r.createdAt)].map(cell).join(","))].join("\n") + "\n";
1402
+ });
1403
+ }
1404
+ async deleteSubscriber(id, ctx) {
1405
+ this.editor(ctx);
1406
+ return this.guard(async () => {
1407
+ const { count } = UUID.test(id) ? await this.db.newsletterSubscriber.deleteMany({ where: { id } }) : { count: 0 };
1408
+ if (!count)
1409
+ throw new NotFoundException("Subscriber not found");
1410
+ });
1411
+ }
1412
+ }
1413
+ //# sourceMappingURL=site.js.map