import * as z from "zod"; import { validatePageBlocks, type SerializedBlock } from "@/lib/blocks"; import { dataSourceSnapshotSchema, evaluateDataSource } from "./data-source"; import type { CharacterIdentity, DataSourceSnapshot, PageIdentity, PageSnapshot, PublicCharacterCard, } from "./types"; const checkpointRetentionMs = 30 * 24 * 60 * 60 * 1_000; export const slugSchema = z .string() .trim() .min(1) .max(120) .regex( /^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*$/u, "Slugs may contain letters, numbers, and single hyphens between words.", ); interface StoredPage extends PageIdentity { blocks: SerializedBlock[]; dependencies: Record; } interface StoredDataSource { id: string; name: string; fields: DataSourceSnapshot["fields"]; currentVersion: number; versions: Map; } export interface ContentRevision { id: string; pageId: string; pageVersion: number; kind: "checkpoint" | "named"; label: string | null; authorId: string; sourceRevisionId: string | null; checkpointHour: string | null; snapshot: { page: PageIdentity; blocks: SerializedBlock[]; dependencies: Record; }; dataSourceVersions: Record; mediaIds: string[]; createdAt: Date; expiresAt: Date | null; } export interface DomainOutboxEvent { id: number; topic: string; aggregateId: string; eventType: | "page.updated" | "directory.updated" | "data-source.updated" | "admin.updated"; payload: { id: string; version: number }; status: "pending" | "processed"; attempts: number; availableAt: Date; createdAt: Date; } interface CharacterAlias { oldSlug: string; characterId: string; } interface PageAlias { oldCharacterSlug: string; oldPageSlug: string; pageId: string; } export interface SavePageInput { pageId: string; expectedVersion: number; authorId: string; title: string; slug: string; visible: boolean; publicNote?: string | null; blocks: SerializedBlock[]; now?: Date; } export type MutationResult = | { status: "accepted"; value: T } | { status: "conflict"; currentVersion: number } | { status: "validation-error"; issues: string[] } | { status: "not-found" }; export type PublicRouteResult = | { type: "page"; snapshot: PageSnapshot } | { type: "redirect"; location: string; status: 308 } | { type: "not-found" }; export interface LifecycleOptions { idFactory?: () => string; now?: () => Date; } function copy(value: T): T { return structuredClone(value); } function hourStart(date: Date): Date { const result = new Date(date); result.setUTCMinutes(0, 0, 0); return result; } function collectMediaIds(value: unknown, result = new Set()): Set { if (Array.isArray(value)) { for (const item of value) collectMediaIds(item, result); return result; } if (!value || typeof value !== "object") return result; for (const [key, item] of Object.entries(value)) { if (key === "mediaId" && typeof item === "string") result.add(item); collectMediaIds(item, result); } return result; } function collectDataSourceIds( value: unknown, result = new Set(), ): Set { if (Array.isArray(value)) { for (const item of value) collectDataSourceIds(item, result); return result; } if (!value || typeof value !== "object") return result; for (const [key, item] of Object.entries(value)) { if (key === "dataSourceId" && typeof item === "string") result.add(item); collectDataSourceIds(item, result); } return result; } export class ContentLifecycle { private readonly characters = new Map(); private readonly pages = new Map(); private readonly dataSources = new Map(); private readonly revisions = new Map(); private readonly characterAliases: CharacterAlias[] = []; private readonly pageAliases: PageAlias[] = []; private readonly outbox: DomainOutboxEvent[] = []; private nextOutboxId = 1; private readonly idFactory: () => string; private readonly clock: () => Date; constructor(options: LifecycleOptions = {}) { this.idFactory = options.idFactory ?? (() => crypto.randomUUID()); this.clock = options.now ?? (() => new Date()); } addCharacter( input: Omit & { visible?: boolean }, ): CharacterIdentity { const slug = slugSchema.parse(input.slug); if ([...this.characters.values()].some((item) => item.slug === slug)) { throw new Error(`Character slug already exists: ${slug}`); } const character: CharacterIdentity = { ...copy(input), slug, visible: input.visible ?? false, }; this.characters.set(character.id, character); return copy(character); } setCharacterVisibility(id: string, visible: boolean): CharacterIdentity | null { const character = this.characters.get(id); if (!character) return null; character.visible = visible; this.enqueue("directory", id, "directory.updated", 1, this.clock()); return copy(character); } renameCharacter(id: string, nextSlug: string): CharacterIdentity | null { const character = this.characters.get(id); if (!character) return null; const slug = slugSchema.parse(nextSlug); if ( [...this.characters.values()].some( (item) => item.id !== id && item.slug === slug, ) ) { throw new Error(`Character slug already exists: ${slug}`); } if (slug !== character.slug) { this.characterAliases.push({ oldSlug: character.slug, characterId: id }); character.slug = slug; this.enqueue("directory", id, "directory.updated", 1, this.clock()); } return copy(character); } addPage( input: Omit & { visible?: boolean; version?: number; blocks?: SerializedBlock[]; }, ): PageIdentity { if (!this.characters.has(input.characterId)) { throw new Error(`Character does not exist: ${input.characterId}`); } const slug = slugSchema.parse(input.slug); if ( [...this.pages.values()].some( (page) => page.characterId === input.characterId && page.slug === slug, ) ) { throw new Error(`Page slug already exists for this character: ${slug}`); } const blocks = copy(input.blocks ?? []); const invalid = validatePageBlocks(blocks).filter( (resolution) => resolution.kind === "invalid", ); if (invalid.length > 0) throw new Error("Initial page blocks are invalid."); const page: StoredPage = { id: input.id, characterId: input.characterId, slug, title: input.title, navOrder: input.navOrder, visible: input.visible ?? false, publicNote: input.publicNote, version: input.version ?? 1, blocks, dependencies: {}, }; this.pages.set(page.id, page); return copy(this.pageIdentity(page)); } addDataSource(input: DataSourceSnapshot): DataSourceSnapshot { const snapshot = dataSourceSnapshotSchema.parse(copy(input)); evaluateDataSource(snapshot); this.dataSources.set(snapshot.id, { id: snapshot.id, name: snapshot.name, fields: snapshot.fields, currentVersion: snapshot.version, versions: new Map([[snapshot.version, snapshot]]), }); return copy(snapshot); } savePage(input: SavePageInput): MutationResult { const page = this.pages.get(input.pageId); if (!page) return { status: "not-found" }; if (page.version !== input.expectedVersion) { return { status: "conflict", currentVersion: page.version }; } const issues: string[] = []; const slugResult = slugSchema.safeParse(input.slug); if (!slugResult.success) { issues.push(...slugResult.error.issues.map((issue) => issue.message)); } if (!input.title.trim()) issues.push("Page title cannot be blank."); const resolutions = validatePageBlocks(input.blocks); for (const resolution of resolutions) { if (resolution.kind === "invalid") { issues.push( ...resolution.issues.map( (issue) => `${resolution.block.type}: ${issue.message}`, ), ); } } const dependencyIds = collectDataSourceIds( input.blocks.map((block) => block.config), ); for (const dependencyId of dependencyIds) { if (!this.dataSources.has(dependencyId)) { issues.push(`Referenced data source does not exist: ${dependencyId}`); } } if (issues.length > 0 || !slugResult.success) { return { status: "validation-error", issues }; } const duplicateSlug = [...this.pages.values()].some( (candidate) => candidate.id !== page.id && candidate.characterId === page.characterId && candidate.slug === slugResult.data, ); if (duplicateSlug) { return { status: "validation-error", issues: [`Page slug already exists: ${slugResult.data}`], }; } const character = this.characters.get(page.characterId)!; if (slugResult.data !== page.slug) { this.pageAliases.push({ oldCharacterSlug: character.slug, oldPageSlug: page.slug, pageId: page.id, }); } page.slug = slugResult.data; page.title = input.title.trim(); page.visible = input.visible; page.publicNote = input.publicNote?.trim() || null; page.blocks = resolutions.map((resolution) => resolution.kind === "ready" ? resolution.block : resolution.block, ); page.version += 1; page.dependencies = Object.fromEntries( [...dependencyIds].map((id) => [id, page.dependencies[id] ?? null]), ); const now = input.now ?? this.clock(); this.upsertCheckpoint(page, input.authorId, now); this.enqueue(`page:${page.id}`, page.id, "page.updated", page.version, now); this.enqueue("directory", page.id, "directory.updated", page.version, now); this.enqueue("admin", page.id, "admin.updated", page.version, now); return { status: "accepted", value: this.snapshotFor(page) }; } saveDataSource( input: Omit & { expectedVersion: number; now?: Date; }, ): MutationResult { const source = this.dataSources.get(input.id); if (!source) return { status: "not-found" }; if (source.currentVersion !== input.expectedVersion) { return { status: "conflict", currentVersion: source.currentVersion }; } const next: DataSourceSnapshot = { id: input.id, name: input.name, version: source.currentVersion + 1, fields: copy(input.fields), rows: copy(input.rows), }; const parsed = dataSourceSnapshotSchema.safeParse(next); if (!parsed.success) { return { status: "validation-error", issues: parsed.error.issues.map((issue) => issue.message), }; } // Formula failures are retained as typed cell errors and do not reject // unrelated valid changes. Structural validation failures do reject. evaluateDataSource(parsed.data); source.currentVersion = next.version; source.name = next.name; source.fields = next.fields; source.versions.set(next.version, copy(next)); const now = input.now ?? this.clock(); this.enqueue( `data-source:${source.id}`, source.id, "data-source.updated", next.version, now, ); for (const page of this.pages.values()) { if ( Object.hasOwn(page.dependencies, source.id) && page.dependencies[source.id] === null ) { this.enqueue(`page:${page.id}`, page.id, "page.updated", page.version, now); } } return { status: "accepted", value: copy(next) }; } createNamedRevision( pageId: string, label: string, authorId: string, now = this.clock(), ): ContentRevision | null { const page = this.pages.get(pageId); if (!page) return null; const revision = this.captureRevision( page, "named", label.trim() || "รุ่นที่บันทึก", authorId, now, null, ); this.revisions.set(revision.id, revision); return copy(revision); } restoreRevision( revisionId: string, expectedVersion: number, authorId: string, now = this.clock(), ): MutationResult { const revision = this.revisions.get(revisionId); if (!revision) return { status: "not-found" }; const page = this.pages.get(revision.pageId); if (!page) return { status: "not-found" }; if (page.version !== expectedVersion) { return { status: "conflict", currentVersion: page.version }; } const restored = revision.snapshot; page.slug = restored.page.slug; page.title = restored.page.title; page.navOrder = restored.page.navOrder; page.visible = restored.page.visible; page.publicNote = restored.page.publicNote; page.blocks = copy(restored.blocks); page.dependencies = Object.fromEntries( Object.keys(restored.dependencies).map((sourceId) => [ sourceId, revision.dataSourceVersions[sourceId], ]), ); page.version += 1; this.upsertCheckpoint(page, authorId, now, revision.id); this.enqueue(`page:${page.id}`, page.id, "page.updated", page.version, now); this.enqueue("directory", page.id, "directory.updated", page.version, now); this.enqueue("admin", page.id, "admin.updated", page.version, now); return { status: "accepted", value: this.snapshotFor(page) }; } useLatestDataSource( pageId: string, dataSourceId: string, expectedVersion: number, authorId: string, now = this.clock(), ): MutationResult { const page = this.pages.get(pageId); if (!page) return { status: "not-found" }; if (page.version !== expectedVersion) { return { status: "conflict", currentVersion: page.version }; } if (!Object.hasOwn(page.dependencies, dataSourceId)) { return { status: "validation-error", issues: ["The page does not reference this data source."], }; } page.dependencies[dataSourceId] = null; page.version += 1; this.upsertCheckpoint(page, authorId, now); this.enqueue(`page:${page.id}`, page.id, "page.updated", page.version, now); this.enqueue("admin", page.id, "admin.updated", page.version, now); return { status: "accepted", value: this.snapshotFor(page) }; } expireCheckpoints(now = this.clock()): string[] { const expired: string[] = []; for (const [id, revision] of this.revisions) { if ( revision.kind === "checkpoint" && revision.expiresAt && revision.expiresAt <= now ) { this.revisions.delete(id); expired.push(id); } } return expired; } getRevision(id: string): ContentRevision | null { const revision = this.revisions.get(id); return revision ? copy(revision) : null; } listRevisions(pageId: string): ContentRevision[] { return [...this.revisions.values()] .filter((revision) => revision.pageId === pageId) .sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()) .map(copy); } getOutbox(): DomainOutboxEvent[] { return this.outbox.map(copy); } markOutboxProcessed(id: number): void { const event = this.outbox.find((item) => item.id === id); if (event) event.status = "processed"; } getDirectory(): PublicCharacterCard[] { return [...this.characters.values()] .filter((character) => character.visible) .map((character) => { const firstPage = this.visiblePagesFor(character.id)[0]; if (!firstPage) return null; return { id: character.id, slug: character.slug, nameTh: character.nameTh, nameEn: character.nameEn, portraitMediaId: character.portraitMediaId, element: character.element, role: character.role, rarity: character.rarity, firstPageSlug: firstPage.slug, } satisfies PublicCharacterCard; }) .filter((item): item is PublicCharacterCard => item !== null) .sort((left, right) => { const leftCharacter = this.characters.get(left.id)!; const rightCharacter = this.characters.get(right.id)!; return ( leftCharacter.sortOrder - rightCharacter.sortOrder || left.nameTh.localeCompare(right.nameTh, "th") ); }); } resolvePublicRoute( characterSlug: string, pageSlug?: string, ): PublicRouteResult { const currentCharacter = [...this.characters.values()].find( (character) => character.slug === characterSlug, ); const characterAlias = this.characterAliases.find( (alias) => alias.oldSlug === characterSlug, ); const character = currentCharacter ?? (characterAlias ? this.characters.get(characterAlias.characterId) : undefined); if (!character?.visible) return { type: "not-found" }; const visiblePages = this.visiblePagesFor(character.id); if (visiblePages.length === 0) return { type: "not-found" }; if (!pageSlug) { return { type: "redirect", location: `/${character.slug}/${visiblePages[0].slug}`, status: 308, }; } const page = visiblePages.find((candidate) => candidate.slug === pageSlug); if (page) { if (!currentCharacter) { return { type: "redirect", location: `/${character.slug}/${page.slug}`, status: 308, }; } return { type: "page", snapshot: this.snapshotFor(page) }; } const pageAlias = this.pageAliases.find( (alias) => alias.oldCharacterSlug === characterSlug && alias.oldPageSlug === pageSlug, ); const target = pageAlias ? this.pages.get(pageAlias.pageId) : undefined; if (target?.visible && target.characterId === character.id) { return { type: "redirect", location: `/${character.slug}/${target.slug}`, status: 308, }; } return { type: "not-found" }; } isMediaPublic(mediaId: string): boolean { for (const page of this.pages.values()) { const character = this.characters.get(page.characterId); if ( character?.visible && page.visible && collectMediaIds(page.blocks.map((block) => block.config)).has(mediaId) ) { return true; } } return false; } isMediaRetained(mediaId: string): boolean { if ( [...this.pages.values()].some((page) => collectMediaIds(page.blocks.map((block) => block.config)).has(mediaId), ) ) { return true; } return [...this.revisions.values()].some((revision) => revision.mediaIds.includes(mediaId), ); } private pageIdentity(page: StoredPage): PageIdentity { return { id: page.id, characterId: page.characterId, slug: page.slug, title: page.title, navOrder: page.navOrder, visible: page.visible, publicNote: page.publicNote, version: page.version, }; } private visiblePagesFor(characterId: string): StoredPage[] { return [...this.pages.values()] .filter((page) => page.characterId === characterId && page.visible) .sort( (left, right) => left.navOrder - right.navOrder || left.title.localeCompare(right.title, "th"), ); } private versionVector(page: StoredPage): Record { return Object.fromEntries( Object.entries(page.dependencies).map(([sourceId, pinnedVersion]) => { const source = this.dataSources.get(sourceId); return [sourceId, pinnedVersion ?? source?.currentVersion ?? 0]; }), ); } private snapshotFor(page: StoredPage): PageSnapshot { const character = this.characters.get(page.characterId); if (!character) throw new Error(`Character does not exist: ${page.characterId}`); const vector = this.versionVector(page); const sources = Object.fromEntries( Object.entries(vector).flatMap(([sourceId, version]) => { const source = this.dataSources.get(sourceId)?.versions.get(version); return source ? [[sourceId, copy(source)] as const] : []; }), ); return { character: copy(character), page: copy(this.pageIdentity(page)), pages: [...this.pages.values()] .filter((candidate) => candidate.characterId === page.characterId) .sort((left, right) => left.navOrder - right.navOrder) .map((candidate) => copy(this.pageIdentity(candidate))), blocks: copy(page.blocks), dataSources: sources, versionVector: vector, }; } private captureRevision( page: StoredPage, kind: ContentRevision["kind"], label: string | null, authorId: string, now: Date, sourceRevisionId: string | null, ): ContentRevision { const checkpointHour = kind === "checkpoint" ? hourStart(now) : null; return { id: this.idFactory(), pageId: page.id, pageVersion: page.version, kind, label, authorId, sourceRevisionId, checkpointHour: checkpointHour?.toISOString() ?? null, snapshot: { page: copy(this.pageIdentity(page)), blocks: copy(page.blocks), dependencies: copy(page.dependencies), }, dataSourceVersions: this.versionVector(page), mediaIds: [ ...collectMediaIds(page.blocks.map((block) => block.config)), ], createdAt: new Date(now), expiresAt: kind === "checkpoint" ? new Date(now.getTime() + checkpointRetentionMs) : null, }; } private upsertCheckpoint( page: StoredPage, authorId: string, now: Date, sourceRevisionId: string | null = null, ): void { const next = this.captureRevision( page, "checkpoint", null, authorId, now, sourceRevisionId, ); const existing = [...this.revisions.values()].find( (revision) => revision.pageId === page.id && revision.kind === "checkpoint" && revision.checkpointHour === next.checkpointHour, ); if (existing) this.revisions.delete(existing.id); this.revisions.set(next.id, next); } private enqueue( topic: string, aggregateId: string, eventType: DomainOutboxEvent["eventType"], version: number, now: Date, ): void { this.outbox.push({ id: this.nextOutboxId, topic, aggregateId, eventType, payload: { id: aggregateId, version }, status: "pending", attempts: 0, availableAt: new Date(now), createdAt: new Date(now), }); this.nextOutboxId += 1; } }