feat(data): add transactional content repository

This commit is contained in:
2026-08-28 16:22:49 +00:00 Unverified
parent 30586a893a
commit d3b09a4e70
3 changed files with 1105 additions and 2 deletions
+10 -2
View File
@@ -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[] {
+762
View File
@@ -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<string, number | null>;
sourceRevisionId?: string | null;
}
function collectNamedIds(
value: unknown,
keyName: "dataSourceId" | "mediaId",
result = new Set<string>(),
): Set<string> {
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<string, unknown> {
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, number>): 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<MutationResult<PageSnapshot>> {
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<MutationResult<PageSnapshot>> => {
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<string, unknown>,
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<string, DataSourceSnapshot> = {};
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<string, unknown>,
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<number>`(
select count(*)::int from ${mediaReferences}
where ${mediaReferences.mediaId} = ${mediaId}
and ${mediaReferences.referenceKind} = 'current'
)`,
revisionReferenceCount: sql<number>`(
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<string, unknown>,
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<DataSourceSnapshot, "version"> & {
expectedVersion: number;
authorId: string;
now?: Date;
},
): Promise<MutationResult<DataSourceSnapshot>> {
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<MutationResult<DataSourceSnapshot>> => {
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<string, unknown>,
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<string | null> {
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<string, unknown>,
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<MutationResult<PageSnapshot>> {
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<string, number | null>;
};
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<MutationResult<PageSnapshot>> {
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<string[]> {
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<number>`(
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<PageSnapshot | null> {
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;
}
+333
View File
@@ -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<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 = 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,
};
}