Files
buzz-sheet/lib/content/queries.ts
T
gunshiz 03149a1499
CI / Verify (push) Successful in 1m25s
CI / Build immutable images and deploy (push) Has been cancelled
feat : update
2026-09-06 15:07:28 +07:00

365 lines
10 KiB
TypeScript

import "server-only";
import { and, asc, eq } from "drizzle-orm";
import { getDb } from "@/db";
import {
blocks,
characters,
dataSources,
dataSourceVersions,
guides,
guidePosterBleeding,
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<PublicCharacterCard[]> {
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<string, PublicCharacterCard>();
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<PageSnapshot> {
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<string, DataSourceSnapshot> = {};
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<PageLookup> {
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<PageSnapshot | null> {
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, posterGuides, bleedingGuides, [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({ guide: guides })
.from(guides)
.where(eq(guides.posterArtMediaId, mediaId)),
db
.select({ guide: guides })
.from(guidePosterBleeding)
.innerJoin(guides, eq(guides.id, guidePosterBleeding.guideId))
.where(eq(guidePosterBleeding.mediaId, 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) ||
posterGuides.some(({ guide }) => guide.isPublic && !guide.trashedAt) ||
bleedingGuides.some(({ guide }) => guide.isPublic && !guide.trashedAt),
retained:
references.length > 0 ||
portraitCharacters.length > 0 ||
posterGuides.length > 0 ||
bleedingGuides.length > 0 ||
Boolean(
mediaRecord &&
(mediaRecord.currentReferenceCount > 0 ||
mediaRecord.revisionReferenceCount > 0),
),
};
}