diff --git a/lib/content/lifecycle.ts b/lib/content/lifecycle.ts index aba0136..37df74b 100644 --- a/lib/content/lifecycle.ts +++ b/lib/content/lifecycle.ts @@ -636,8 +636,16 @@ export class ContentLifecycle { } private pageIdentity(page: StoredPage): PageIdentity { - const { blocks: _blocks, dependencies: _dependencies, ...identity } = page; - return identity; + 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[] { diff --git a/lib/content/mutations.ts b/lib/content/mutations.ts new file mode 100644 index 0000000..e0cb237 --- /dev/null +++ b/lib/content/mutations.ts @@ -0,0 +1,762 @@ +import "server-only"; + +import { createHash } from "node:crypto"; + +import { and, eq, inArray, isNull, lt, sql } from "drizzle-orm"; + +import { getDb } from "@/db"; +import { + blocks, + characters, + dataSources, + dataSourceVersions, + media, + mediaReferences, + outboxEvents, + pageDataSources, + pages, + publicSnapshots, + revisions, + slugAliases, +} from "@/db/schema"; +import { validatePageBlocks, type SerializedBlock } from "@/lib/blocks"; + +import { dataSourceSnapshotSchema, evaluateDataSource } from "./data-source"; +import { slugSchema, type MutationResult, type SavePageInput } from "./lifecycle"; +import { getAdminPage } from "./queries"; +import type { DataSourceSnapshot, PageSnapshot } from "./types"; + +interface SavePageOptions { + dependencyPins?: Record; + sourceRevisionId?: string | null; +} + +function collectNamedIds( + value: unknown, + keyName: "dataSourceId" | "mediaId", + result = new Set(), +): Set { + if (Array.isArray(value)) { + for (const item of value) collectNamedIds(item, keyName, result); + return result; + } + if (!value || typeof value !== "object") return result; + for (const [key, nested] of Object.entries(value)) { + if (key === keyName && typeof nested === "string") result.add(nested); + collectNamedIds(nested, keyName, result); + } + return result; +} + +function jsonObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function checkpointHour(date: Date): Date { + const hour = new Date(date); + hour.setUTCMinutes(0, 0, 0); + return hour; +} + +function vectorHash(vector: Record): string { + const stableEntries = Object.entries(vector).sort(([left], [right]) => + left.localeCompare(right), + ); + return createHash("sha256").update(JSON.stringify(stableEntries)).digest("hex"); +} + +function pageIdentity(page: typeof pages.$inferSelect) { + 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, + }; +} + +function characterIdentity(character: typeof characters.$inferSelect) { + return { + id: character.id, + slug: character.slug, + nameTh: character.nameTh, + nameEn: character.nameEn, + description: character.description, + portraitMediaId: character.portraitMediaId, + element: character.element, + role: character.role, + rarity: character.rarity, + sortOrder: character.sortOrder, + visible: character.visible, + }; +} + +export async function savePageDraft( + input: SavePageInput, + options: SavePageOptions = {}, +): Promise> { + 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}`, + ), + ); + } else if ( + resolution.kind === "unsupported" && + !jsonObject(resolution.block.config) + ) { + issues.push(`${resolution.block.type}: configuration must be an object.`); + } + } + if (issues.length > 0 || !slugResult.success) { + return { status: "validation-error", issues }; + } + + const normalizedBlocks = resolutions.map((resolution) => + resolution.kind === "ready" ? resolution.block : resolution.block, + ); + const dependencyIds = [ + ...collectNamedIds( + normalizedBlocks.map((block) => block.config), + "dataSourceId", + ), + ]; + const mediaIds = [ + ...collectNamedIds( + normalizedBlocks.map((block) => block.config), + "mediaId", + ), + ]; + const now = input.now ?? new Date(); + const db = getDb(); + + return db.transaction(async (tx): Promise> => { + const [current] = await tx + .select({ page: pages, character: characters }) + .from(pages) + .innerJoin(characters, eq(characters.id, pages.characterId)) + .where(eq(pages.id, input.pageId)) + .limit(1); + if (!current) return { status: "not-found" }; + if (current.page.version !== input.expectedVersion) { + return { status: "conflict", currentVersion: current.page.version }; + } + + if (dependencyIds.length > 0) { + const existingSources = await tx + .select({ id: dataSources.id }) + .from(dataSources) + .where(inArray(dataSources.id, dependencyIds)); + const existing = new Set(existingSources.map((source) => source.id)); + const missing = dependencyIds.filter((id) => !existing.has(id)); + if (missing.length > 0) { + return { + status: "validation-error", + issues: missing.map((id) => `Referenced data source does not exist: ${id}`), + }; + } + } + + const [updated] = await tx + .update(pages) + .set({ + slug: slugResult.data, + title: input.title.trim(), + visible: input.visible, + publicNote: input.publicNote?.trim() || null, + version: input.expectedVersion + 1, + updatedAt: now, + }) + .where( + and( + eq(pages.id, input.pageId), + eq(pages.version, input.expectedVersion), + ), + ) + .returning(); + if (!updated) { + const [latest] = await tx + .select({ version: pages.version }) + .from(pages) + .where(eq(pages.id, input.pageId)) + .limit(1); + return { + status: "conflict", + currentVersion: latest?.version ?? input.expectedVersion, + }; + } + + if (current.page.slug !== updated.slug) { + await tx + .insert(slugAliases) + .values({ + kind: "page", + oldCharacterSlug: current.character.slug, + oldPageSlug: current.page.slug, + targetCharacterId: current.character.id, + targetPageId: current.page.id, + }) + .onConflictDoNothing(); + } + + await tx.delete(blocks).where(eq(blocks.pageId, updated.id)); + if (normalizedBlocks.length > 0) { + await tx.insert(blocks).values( + normalizedBlocks.map((block) => ({ + id: block.id, + pageId: updated.id, + type: block.type, + schemaVersion: block.schemaVersion, + config: block.config as Record, + responsiveLayout: jsonObject(block.responsiveLayout) + ? block.responsiveLayout + : {}, + sortOrder: block.sortOrder, + createdAt: now, + updatedAt: now, + })), + ); + } + + const existingDependencies = await tx + .select() + .from(pageDataSources) + .where(eq(pageDataSources.pageId, updated.id)); + const previousPins = Object.fromEntries( + existingDependencies.map((dependency) => [ + dependency.dataSourceId, + dependency.pinnedVersion, + ]), + ); + const requestedPins = { + ...previousPins, + ...options.dependencyPins, + }; + await tx + .delete(pageDataSources) + .where(eq(pageDataSources.pageId, updated.id)); + if (dependencyIds.length > 0) { + await tx.insert(pageDataSources).values( + dependencyIds.map((dataSourceId) => ({ + pageId: updated.id, + dataSourceId, + pinnedVersion: requestedPins[dataSourceId] ?? null, + })), + ); + } + + const sourceRows = + dependencyIds.length === 0 + ? [] + : await tx + .select() + .from(dataSources) + .where(inArray(dataSources.id, dependencyIds)); + const versionVector = Object.fromEntries( + sourceRows.map((source) => [ + source.id, + requestedPins[source.id] ?? source.currentVersion, + ]), + ); + const sourceSnapshots: Record = {}; + for (const source of sourceRows) { + const version = versionVector[source.id]; + const [versionRow] = await tx + .select() + .from(dataSourceVersions) + .where( + and( + eq(dataSourceVersions.dataSourceId, source.id), + eq(dataSourceVersions.version, version), + ), + ) + .limit(1); + if (versionRow) { + sourceSnapshots[source.id] = { + id: source.id, + name: source.name, + version, + fields: source.fields as DataSourceSnapshot["fields"], + rows: versionRow.rows as DataSourceSnapshot["rows"], + }; + } + } + + const navigationRows = await tx + .select() + .from(pages) + .where(eq(pages.characterId, updated.characterId)) + .orderBy(pages.navOrder, pages.title); + const snapshot: PageSnapshot = { + character: characterIdentity(current.character), + page: pageIdentity(updated), + pages: navigationRows.map(pageIdentity), + blocks: normalizedBlocks, + dataSources: sourceSnapshots, + versionVector, + }; + + const revisionSnapshot = { + page: snapshot.page, + blocks: snapshot.blocks, + dependencies: Object.fromEntries( + dependencyIds.map((id) => [id, requestedPins[id] ?? null]), + ), + }; + const hour = checkpointHour(now); + const [existingCheckpoint] = await tx + .select({ id: revisions.id }) + .from(revisions) + .where( + and( + eq(revisions.pageId, updated.id), + eq(revisions.kind, "checkpoint"), + eq(revisions.checkpointHour, hour), + ), + ) + .limit(1); + const revisionValues = { + pageVersion: updated.version, + kind: "checkpoint", + label: null, + authorId: input.authorId, + sourceRevisionId: options.sourceRevisionId ?? null, + checkpointHour: hour, + snapshot: revisionSnapshot as unknown as Record, + dataSourceVersions: versionVector, + mediaIds, + createdAt: now, + expiresAt: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1_000), + }; + const revisionId = existingCheckpoint + ? existingCheckpoint.id + : crypto.randomUUID(); + if (existingCheckpoint) { + await tx + .update(revisions) + .set(revisionValues) + .where(eq(revisions.id, revisionId)); + } else { + await tx.insert(revisions).values({ + id: revisionId, + pageId: updated.id, + ...revisionValues, + }); + } + + const priorCurrentMedia = await tx + .select({ mediaId: mediaReferences.mediaId }) + .from(mediaReferences) + .where( + and( + eq(mediaReferences.ownerType, "page"), + eq(mediaReferences.ownerId, updated.id), + eq(mediaReferences.referenceKind, "current"), + ), + ); + const priorRevisionMedia = await tx + .select({ mediaId: mediaReferences.mediaId }) + .from(mediaReferences) + .where( + and( + eq(mediaReferences.ownerType, "revision"), + eq(mediaReferences.ownerId, revisionId), + eq(mediaReferences.referenceKind, "revision"), + ), + ); + await tx + .delete(mediaReferences) + .where( + and( + eq(mediaReferences.ownerType, "page"), + eq(mediaReferences.ownerId, updated.id), + eq(mediaReferences.referenceKind, "current"), + ), + ); + await tx + .delete(mediaReferences) + .where( + and( + eq(mediaReferences.ownerType, "revision"), + eq(mediaReferences.ownerId, revisionId), + eq(mediaReferences.referenceKind, "revision"), + ), + ); + if (mediaIds.length > 0) { + await tx.insert(mediaReferences).values([ + ...mediaIds.map((mediaId) => ({ + mediaId, + ownerType: "page", + ownerId: updated.id, + referenceKind: "current", + createdAt: now, + })), + ...mediaIds.map((mediaId) => ({ + mediaId, + ownerType: "revision", + ownerId: revisionId, + referenceKind: "revision", + createdAt: now, + })), + ]); + } + const affectedMediaIds = [ + ...new Set([ + ...mediaIds, + ...priorCurrentMedia.map((item) => item.mediaId), + ...priorRevisionMedia.map((item) => item.mediaId), + ]), + ]; + for (const mediaId of affectedMediaIds) { + await tx + .update(media) + .set({ + currentReferenceCount: sql`( + select count(*)::int from ${mediaReferences} + where ${mediaReferences.mediaId} = ${mediaId} + and ${mediaReferences.referenceKind} = 'current' + )`, + revisionReferenceCount: sql`( + select count(*)::int from ${mediaReferences} + where ${mediaReferences.mediaId} = ${mediaId} + and ${mediaReferences.referenceKind} = 'revision' + )`, + }) + .where(eq(media.id, mediaId)); + } + + const publicDto: PageSnapshot = { + ...snapshot, + pages: snapshot.pages.filter((item) => item.visible), + }; + await tx.insert(publicSnapshots).values({ + pageId: updated.id, + pageVersion: updated.version, + versionVector, + vectorHash: vectorHash(versionVector), + dto: publicDto as unknown as Record, + visible: current.character.visible && updated.visible, + createdAt: now, + }); + + await tx.insert(outboxEvents).values([ + { + topic: `page:${updated.id}`, + aggregateId: updated.id, + eventType: "page.updated", + payload: { id: updated.id, version: updated.version }, + createdAt: now, + }, + { + topic: "directory", + aggregateId: updated.id, + eventType: "directory.updated", + payload: { id: updated.id, version: updated.version }, + createdAt: now, + }, + { + topic: "admin", + aggregateId: updated.id, + eventType: "admin.updated", + payload: { id: updated.id, version: updated.version }, + createdAt: now, + }, + ]); + + return { status: "accepted", value: snapshot }; + }); +} + +export async function saveDataSourceVersion( + input: Omit & { + expectedVersion: number; + authorId: string; + now?: Date; + }, +): Promise> { + const next: DataSourceSnapshot = { + id: input.id, + name: input.name, + version: input.expectedVersion + 1, + fields: input.fields, + rows: input.rows, + }; + const parsed = dataSourceSnapshotSchema.safeParse(next); + if (!parsed.success) { + return { + status: "validation-error", + issues: parsed.error.issues.map((issue) => issue.message), + }; + } + const evaluation = evaluateDataSource(parsed.data); + const now = input.now ?? new Date(); + const db = getDb(); + + return db.transaction(async (tx): Promise> => { + const [current] = await tx + .select() + .from(dataSources) + .where(eq(dataSources.id, input.id)) + .limit(1); + if (!current) return { status: "not-found" }; + if (current.currentVersion !== input.expectedVersion) { + return { status: "conflict", currentVersion: current.currentVersion }; + } + + const [updated] = await tx + .update(dataSources) + .set({ + name: parsed.data.name, + fields: parsed.data.fields, + currentVersion: parsed.data.version, + updatedAt: now, + }) + .where( + and( + eq(dataSources.id, input.id), + eq(dataSources.currentVersion, input.expectedVersion), + ), + ) + .returning(); + if (!updated) { + return { status: "conflict", currentVersion: current.currentVersion }; + } + + await tx.insert(dataSourceVersions).values({ + dataSourceId: input.id, + version: parsed.data.version, + rows: parsed.data.rows, + evaluation: evaluation as unknown as Record, + createdById: input.authorId, + createdAt: now, + }); + const consumers = await tx + .select({ pageId: pageDataSources.pageId, pageVersion: pages.version }) + .from(pageDataSources) + .innerJoin(pages, eq(pages.id, pageDataSources.pageId)) + .where( + and( + eq(pageDataSources.dataSourceId, input.id), + isNull(pageDataSources.pinnedVersion), + ), + ); + await tx.insert(outboxEvents).values([ + { + topic: `data-source:${input.id}`, + aggregateId: input.id, + eventType: "data-source.updated", + payload: { id: input.id, version: parsed.data.version }, + createdAt: now, + }, + ...consumers.map((consumer) => ({ + topic: `page:${consumer.pageId}`, + aggregateId: consumer.pageId, + eventType: "page.updated", + payload: { id: consumer.pageId, version: consumer.pageVersion }, + createdAt: now, + })), + { + topic: "admin", + aggregateId: input.id, + eventType: "admin.updated", + payload: { id: input.id, version: parsed.data.version }, + createdAt: now, + }, + ]); + + return { status: "accepted", value: parsed.data }; + }); +} + +export async function createNamedRevision( + pageId: string, + label: string, + authorId: string, + now = new Date(), +): Promise { + const snapshot = await getAdminPageById(pageId); + if (!snapshot) return null; + const mediaIds = [ + ...collectNamedIds( + snapshot.blocks.map((block) => block.config), + "mediaId", + ), + ]; + const id = crypto.randomUUID(); + const dependencies = Object.fromEntries( + Object.keys(snapshot.versionVector).map((sourceId) => [sourceId, null]), + ); + const db = getDb(); + await db.transaction(async (tx) => { + await tx.insert(revisions).values({ + id, + pageId, + pageVersion: snapshot.page.version, + kind: "named", + label: label.trim() || "รุ่นที่บันทึก", + authorId, + snapshot: { + page: snapshot.page, + blocks: snapshot.blocks, + dependencies, + } as unknown as Record, + dataSourceVersions: snapshot.versionVector, + mediaIds, + createdAt: now, + expiresAt: null, + }); + if (mediaIds.length > 0) { + await tx.insert(mediaReferences).values( + mediaIds.map((mediaId) => ({ + mediaId, + ownerType: "revision", + ownerId: id, + referenceKind: "revision", + createdAt: now, + })), + ); + } + }); + return id; +} + +export async function restoreRevision( + revisionId: string, + expectedVersion: number, + authorId: string, +): Promise> { + const db = getDb(); + const [revision] = await db + .select() + .from(revisions) + .where(eq(revisions.id, revisionId)) + .limit(1); + if (!revision) return { status: "not-found" }; + const snapshot = revision.snapshot as { + page: PageSnapshot["page"]; + blocks: SerializedBlock[]; + dependencies: Record; + }; + + return savePageDraft( + { + pageId: revision.pageId, + expectedVersion, + authorId, + title: snapshot.page.title, + slug: snapshot.page.slug, + visible: snapshot.page.visible, + publicNote: snapshot.page.publicNote, + blocks: snapshot.blocks, + }, + { + dependencyPins: revision.dataSourceVersions, + sourceRevisionId: revision.id, + }, + ); +} + +export async function useLatestDataSource( + pageId: string, + dataSourceId: string, + expectedVersion: number, + authorId: string, +): Promise> { + const snapshot = await getAdminPageById(pageId); + if (!snapshot) return { status: "not-found" }; + if (snapshot.page.version !== expectedVersion) { + return { status: "conflict", currentVersion: snapshot.page.version }; + } + if (!Object.hasOwn(snapshot.versionVector, dataSourceId)) { + return { + status: "validation-error", + issues: ["The page does not reference this data source."], + }; + } + return savePageDraft( + { + pageId, + expectedVersion, + authorId, + title: snapshot.page.title, + slug: snapshot.page.slug, + visible: snapshot.page.visible, + publicNote: snapshot.page.publicNote, + blocks: snapshot.blocks as SerializedBlock[], + }, + { dependencyPins: { [dataSourceId]: null } }, + ); +} + +export async function expireOldCheckpoints(now = new Date()): Promise { + const db = getDb(); + return db.transaction(async (tx) => { + const expired = await tx + .select({ id: revisions.id }) + .from(revisions) + .where( + and( + eq(revisions.kind, "checkpoint"), + lt(revisions.expiresAt, now), + ), + ); + if (expired.length === 0) return []; + const ids = expired.map((revision) => revision.id); + const affected = await tx + .select({ mediaId: mediaReferences.mediaId }) + .from(mediaReferences) + .where( + and( + eq(mediaReferences.ownerType, "revision"), + inArray(mediaReferences.ownerId, ids), + ), + ); + await tx + .delete(mediaReferences) + .where( + and( + eq(mediaReferences.ownerType, "revision"), + inArray(mediaReferences.ownerId, ids), + ), + ); + await tx.delete(revisions).where(inArray(revisions.id, ids)); + for (const mediaId of new Set(affected.map((item) => item.mediaId))) { + await tx + .update(media) + .set({ + revisionReferenceCount: sql`( + select count(*)::int from ${mediaReferences} + where ${mediaReferences.mediaId} = ${mediaId} + and ${mediaReferences.referenceKind} = 'revision' + )`, + }) + .where(eq(media.id, mediaId)); + } + return ids; + }); +} + +async function getAdminPageById(pageId: string): Promise { + const db = getDb(); + const [identity] = await db + .select({ characterSlug: characters.slug, pageSlug: pages.slug }) + .from(pages) + .innerJoin(characters, eq(characters.id, pages.characterId)) + .where(eq(pages.id, pageId)) + .limit(1); + return identity + ? getAdminPage(identity.characterSlug, identity.pageSlug) + : null; +} diff --git a/lib/content/queries.ts b/lib/content/queries.ts new file mode 100644 index 0000000..529855e --- /dev/null +++ b/lib/content/queries.ts @@ -0,0 +1,333 @@ +import "server-only"; + +import { and, asc, eq } from "drizzle-orm"; + +import { getDb } from "@/db"; +import { + blocks, + characters, + dataSources, + dataSourceVersions, + pageDataSources, + pages, + slugAliases, + templates, +} from "@/db/schema"; + +import type { + CharacterIdentity, + DataSourceSnapshot, + PageIdentity, + PageSnapshot, + PublicCharacterCard, +} from "./types"; + +export type PageLookup = + | { type: "page"; snapshot: PageSnapshot } + | { type: "redirect"; location: string } + | { type: "not-found" }; + +function characterIdentity( + character: typeof characters.$inferSelect, +): CharacterIdentity { + return { + id: character.id, + slug: character.slug, + nameTh: character.nameTh, + nameEn: character.nameEn, + description: character.description, + portraitMediaId: character.portraitMediaId, + element: character.element, + role: character.role, + rarity: character.rarity, + sortOrder: character.sortOrder, + visible: character.visible, + }; +} + +function pageIdentity(page: typeof pages.$inferSelect): 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, + }; +} + +export async function listPublicCharacters(): Promise { + const db = getDb(); + const rows = await db + .select({ character: characters, page: pages }) + .from(characters) + .innerJoin( + pages, + and(eq(pages.characterId, characters.id), eq(pages.visible, true)), + ) + .where(eq(characters.visible, true)) + .orderBy(asc(characters.sortOrder), asc(pages.navOrder), asc(pages.title)); + + const directory = new Map(); + for (const { character, page } of rows) { + if (directory.has(character.id)) continue; + directory.set(character.id, { + 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: page.slug, + }); + } + return [...directory.values()]; +} + +async function buildPageSnapshot( + character: typeof characters.$inferSelect, + page: typeof pages.$inferSelect, + includeHidden: boolean, +): Promise { + const db = getDb(); + const [navigation, blockRows, dependencies] = await Promise.all([ + db + .select() + .from(pages) + .where( + includeHidden + ? eq(pages.characterId, character.id) + : and(eq(pages.characterId, character.id), eq(pages.visible, true)), + ) + .orderBy(asc(pages.navOrder), asc(pages.title)), + db + .select() + .from(blocks) + .where(eq(blocks.pageId, page.id)) + .orderBy(asc(blocks.sortOrder)), + db + .select({ + source: dataSources, + pinnedVersion: pageDataSources.pinnedVersion, + }) + .from(pageDataSources) + .innerJoin(dataSources, eq(dataSources.id, pageDataSources.dataSourceId)) + .where(eq(pageDataSources.pageId, page.id)), + ]); + + const versionVector = Object.fromEntries( + dependencies.map(({ source, pinnedVersion }) => [ + source.id, + pinnedVersion ?? source.currentVersion, + ]), + ); + const sourceSnapshots: Record = {}; + + await Promise.all( + dependencies.map(async ({ source, pinnedVersion }) => { + const version = pinnedVersion ?? source.currentVersion; + const [versionRow] = await db + .select() + .from(dataSourceVersions) + .where( + and( + eq(dataSourceVersions.dataSourceId, source.id), + eq(dataSourceVersions.version, version), + ), + ) + .limit(1); + if (!versionRow) return; + sourceSnapshots[source.id] = { + id: source.id, + name: source.name, + version, + fields: source.fields as DataSourceSnapshot["fields"], + rows: versionRow.rows as DataSourceSnapshot["rows"], + }; + }), + ); + + return { + character: characterIdentity(character), + page: pageIdentity(page), + pages: navigation.map(pageIdentity), + blocks: blockRows.map((block) => ({ + id: block.id, + type: block.type, + schemaVersion: block.schemaVersion, + config: block.config, + responsiveLayout: block.responsiveLayout, + sortOrder: block.sortOrder, + })), + dataSources: sourceSnapshots, + versionVector, + }; +} + +export async function getPublicPage( + requestedCharacterSlug: string, + requestedPageSlug?: string, +): Promise { + const db = getDb(); + let [character] = await db + .select() + .from(characters) + .where( + and( + eq(characters.slug, requestedCharacterSlug), + eq(characters.visible, true), + ), + ) + .limit(1); + const characterIsAlias = !character; + + if (!character) { + const [alias] = await db + .select({ target: characters }) + .from(slugAliases) + .innerJoin( + characters, + eq(characters.id, slugAliases.targetCharacterId), + ) + .where( + and( + eq(slugAliases.kind, "character"), + eq(slugAliases.oldCharacterSlug, requestedCharacterSlug), + eq(characters.visible, true), + ), + ) + .limit(1); + character = alias?.target; + } + + if (!character) return { type: "not-found" }; + + const visiblePages = await db + .select() + .from(pages) + .where( + and(eq(pages.characterId, character.id), eq(pages.visible, true)), + ) + .orderBy(asc(pages.navOrder), asc(pages.title)); + + if (visiblePages.length === 0) return { type: "not-found" }; + if (!requestedPageSlug) { + return { + type: "redirect", + location: `/${character.slug}/${visiblePages[0].slug}`, + }; + } + + const page = visiblePages.find((item) => item.slug === requestedPageSlug); + if (page) { + if (characterIsAlias) { + return { + type: "redirect", + location: `/${character.slug}/${page.slug}`, + }; + } + return { + type: "page", + snapshot: await buildPageSnapshot(character, page, false), + }; + } + + const [alias] = await db + .select({ target: pages }) + .from(slugAliases) + .innerJoin(pages, eq(pages.id, slugAliases.targetPageId)) + .where( + and( + eq(slugAliases.kind, "page"), + eq(slugAliases.oldCharacterSlug, requestedCharacterSlug), + eq(slugAliases.oldPageSlug, requestedPageSlug), + eq(pages.visible, true), + ), + ) + .limit(1); + + if (!alias?.target || alias.target.characterId !== character.id) { + return { type: "not-found" }; + } + return { + type: "redirect", + location: `/${character.slug}/${alias.target.slug}`, + }; +} + +export async function getAdminPage( + characterSlug: string, + pageSlug: string, +): Promise { + const db = getDb(); + const [row] = await db + .select({ character: characters, page: pages }) + .from(characters) + .innerJoin(pages, eq(pages.characterId, characters.id)) + .where( + and(eq(characters.slug, characterSlug), eq(pages.slug, pageSlug)), + ) + .limit(1); + return row + ? buildPageSnapshot(row.character, row.page, true) + : null; +} + +export async function getAdminNavigation() { + const db = getDb(); + const [characterRows, pageRows, templateRows] = await Promise.all([ + db.select().from(characters).orderBy(asc(characters.sortOrder), asc(characters.nameTh)), + db.select().from(pages).orderBy(asc(pages.navOrder), asc(pages.title)), + db.select().from(templates).orderBy(asc(templates.name)), + ]); + return { + characters: characterRows.map((character) => ({ + ...characterIdentity(character), + pages: pageRows + .filter((page) => page.characterId === character.id) + .map(pageIdentity), + })), + templates: templateRows.map((template) => ({ + id: template.id, + slug: template.slug, + name: template.name, + description: template.description, + category: template.category, + builtIn: template.builtIn, + })), + }; +} + +export async function getMediaVisibility(mediaId: string): Promise<{ + public: boolean; + retained: boolean; +}> { + const db = getDb(); + const referencedBlocks = await db + .select({ block: blocks, page: pages, character: characters }) + .from(blocks) + .innerJoin(pages, eq(pages.id, blocks.pageId)) + .innerJoin(characters, eq(characters.id, pages.characterId)); + + const hasMediaId = (value: unknown): boolean => { + if (Array.isArray(value)) return value.some(hasMediaId); + if (!value || typeof value !== "object") return false; + return Object.entries(value).some( + ([key, nested]) => + (key === "mediaId" && nested === mediaId) || hasMediaId(nested), + ); + }; + const references = referencedBlocks.filter(({ block }) => + hasMediaId(block.config), + ); + return { + public: references.some( + ({ page, character }) => page.visible && character.visible, + ), + retained: references.length > 0, + }; +}