import "server-only"; import { and, asc, eq } from "drizzle-orm"; import { getDb } from "@/db"; import { blocks, characters, dataSources, dataSourceVersions, media, 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, portraitCharacters, [mediaRecord]] = await Promise.all([ db .select({ block: blocks, page: pages, character: characters }) .from(blocks) .innerJoin(pages, eq(pages.id, blocks.pageId)) .innerJoin(characters, eq(characters.id, pages.characterId)), db .select({ character: characters }) .from(characters) .where(eq(characters.portraitMediaId, mediaId)), db.select().from(media).where(eq(media.id, mediaId)).limit(1), ]); 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, ) || portraitCharacters.some(({ character }) => character.visible), retained: references.length > 0 || portraitCharacters.length > 0 || Boolean( mediaRecord && (mediaRecord.currentReferenceCount > 0 || mediaRecord.revisionReferenceCount > 0), ), }; }