Files
buzz-sheet/lib/guides/mutations.ts
T
gunshiz 7708a2df31
CI / Verify (push) Successful in 59s
CI / Build immutable images and deploy (push) Successful in 1m58s
feat(admin) : persist drafts and add constellation conditions
2026-09-02 12:40:13 +07:00

242 lines
14 KiB
TypeScript

import "server-only";
import { and, eq, inArray, sql } from "drizzle-orm";
import { getDb } from "@/db";
import {
artifactGroups,
artifactProfiles,
artifactRecommendations,
catalogArtifacts,
catalogCharacters,
catalogWeapons,
constellationConditions,
constellationRecommendations,
extraSectionRows,
extraSections,
guides,
guideSections,
media,
outboxEvents,
teamCompositions,
teamMembers,
weaponRecommendations,
} from "@/db/schema";
import {
artifactSectionSchema,
constellationSectionSchema,
createGuideSchema,
extraSectionSchema,
slugFromCharacterName,
teamSectionSchema,
updateGuideSchema,
weaponSectionSchema,
} from "@/lib/guides/schemas";
import { processCatalogSync } from "@/lib/catalog/sync";
const DEFAULT_SECTIONS = [
{ kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 },
{ kind: "artifact", slug: "artifact", title: "Artifacts & Stats", sortOrder: 1 },
{ kind: "constellations", slug: "constellations", title: "Constellations", sortOrder: 2 },
{ kind: "team", slug: "team", title: "Team Calc", sortOrder: 3 },
] as const;
export class GuideVersionConflictError extends Error {
constructor(readonly currentVersion: number) {
super("ข้อมูลถูกแก้ไขจากแท็บอื่น กด Save now เพื่อบันทึก Local draft ทับเวอร์ชันล่าสุด");
this.name = "GuideVersionConflictError";
}
}
async function emitGuideEvents(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, version: number) {
await tx.insert(outboxEvents).values([
{ topic: "directory", aggregateId: guideId, eventType: "directory.updated", payload: { id: guideId, version } },
{ topic: `page:${guideId}`, aggregateId: guideId, eventType: "page.updated", payload: { id: guideId, version } },
{ topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version } },
]);
}
export async function createGuide(input: unknown) {
const parsed = createGuideSchema.parse(input);
const db = getDb();
const [character] = await db.select().from(catalogCharacters).where(eq(catalogCharacters.key, parsed.characterKey)).limit(1);
if (!character) throw new Error("ไม่พบตัวละครใน Catalog");
const [cover] = await db.select().from(media).where(and(eq(media.id, parsed.coverMediaId), eq(media.status, "ready"))).limit(1);
if (!cover) throw new Error("กรุณาอัปโหลดภาพ Guide ให้สำเร็จ");
const baseSlug = slugFromCharacterName(character.name, character.key);
const [slugCollision] = await db.select({ id: guides.id }).from(guides).where(eq(guides.slug, baseSlug)).limit(1);
const slug = slugCollision ? `${baseSlug}-${character.key.toLowerCase()}` : baseSlug;
return db.transaction(async (tx) => {
const [guide] = await tx.insert(guides).values({
characterKey: character.key,
slug,
name: character.name,
overview: parsed.overview,
coverMediaId: parsed.coverMediaId,
}).returning();
await tx.insert(guideSections).values(DEFAULT_SECTIONS.map((section) => ({ ...section, guideId: guide.id })));
await tx.insert(artifactGroups).values([
{ guideId: guide.id, name: "Late Game", sortOrder: 0 },
{ guideId: guide.id, name: "Early Game", sortOrder: 1 },
]);
await tx.insert(artifactProfiles).values({ guideId: guide.id });
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, parsed.coverMediaId));
await emitGuideEvents(tx, guide.id, guide.version);
return guide;
});
}
async function advanceGuideVersion(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, expectedVersion: number) {
const [guide] = await tx.update(guides).set({ version: expectedVersion + 1 }).where(and(eq(guides.id, guideId), eq(guides.version, expectedVersion), sql`${guides.trashedAt} is null`)).returning();
if (!guide) {
const [current] = await tx.select({ version: guides.version }).from(guides).where(eq(guides.id, guideId)).limit(1);
if (current) throw new GuideVersionConflictError(current.version);
throw new Error("ไม่พบ Guide");
}
await emitGuideEvents(tx, guide.id, guide.version);
return guide;
}
export async function saveWeapons(guideId: string, input: unknown) {
const data = weaponSectionSchema.parse(input);
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!guide) throw new Error("ไม่พบ Guide");
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "weapon")));
await tx.delete(weaponRecommendations).where(eq(weaponRecommendations.guideId, guideId));
if (data.rows.length) await tx.insert(weaponRecommendations).values(data.rows.map((row, sortOrder) => ({ ...row, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, guideId, sortOrder })));
return updated;
});
}
export async function updateGuideBasics(guideId: string, input: unknown) {
const data = updateGuideSchema.parse(input);
const [current] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!current) throw new Error("ไม่พบ Guide");
const [cover] = await getDb().select().from(media).where(and(eq(media.id, data.coverMediaId), eq(media.status, "ready"))).limit(1);
if (!cover) throw new Error("ไม่พบภาพ Guide ที่อัปโหลดแล้ว");
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.update(guides).set({ overview: data.overview, coverMediaId: data.coverMediaId }).where(eq(guides.id, guideId));
if (current.coverMediaId !== data.coverMediaId) {
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, current.coverMediaId));
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, data.coverMediaId));
}
return updated;
});
}
export async function saveArtifacts(guideId: string, input: unknown) {
const data = artifactSectionSchema.parse(input);
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!guide) throw new Error("ไม่พบ Guide");
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId));
for (const [sortOrder, group] of data.groups.entries()) {
const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder }).returning();
if (group.rows.length) await tx.insert(artifactRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, percent: row.percent.toString(), groupId: created.id, sortOrder: rowOrder })));
}
await tx.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note } });
return updated;
});
}
export async function saveConstellations(guideId: string, input: unknown) {
const data = constellationSectionSchema.parse(input);
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "constellations")));
await tx.delete(constellationConditions).where(eq(constellationConditions.guideId, guideId));
for (const [sortOrder, group] of data.groups.entries()) {
const [created] = await tx.insert(constellationConditions).values({ guideId, note: group.note, sortOrder }).returning();
if (group.rows.length) await tx.insert(constellationRecommendations).values(group.rows.map((row) => ({ ...row, cumulativePercent: row.cumulativePercent.toString(), conditionId: created.id })));
}
return updated;
});
}
export async function saveTeams(guideId: string, input: unknown) {
const data = teamSectionSchema.parse(input);
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!guide) throw new Error("ไม่พบ Guide");
const members = data.teams.flatMap((team) => team.members);
const characterKeys = [...new Set(members.map((member) => member.characterKey))];
const weaponKeys = [...new Set(members.map((member) => member.weaponKey).filter((key): key is string => Boolean(key)))];
const artifactKeys = [...new Set(members.flatMap((member) => [member.artifactKey, member.secondArtifactKey]).filter((key): key is string => Boolean(key)))];
const [characters, weapons, artifacts] = await Promise.all([
characterKeys.length ? getDb().select().from(catalogCharacters).where(inArray(catalogCharacters.key, characterKeys)) : [],
weaponKeys.length ? getDb().select().from(catalogWeapons).where(inArray(catalogWeapons.key, weaponKeys)) : [],
artifactKeys.length ? getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, artifactKeys)) : [],
]);
if (characters.length !== characterKeys.length) throw new Error("ไม่พบตัวละครบางรายการใน Catalog");
if (weapons.length !== weaponKeys.length) throw new Error("ไม่พบอาวุธบางรายการใน Catalog");
if (artifacts.length !== artifactKeys.length) throw new Error("ไม่พบ Artifact บางรายการใน Catalog");
for (const member of members) {
const character = characters.find((item) => item.key === member.characterKey);
const weapon = weapons.find((item) => item.key === member.weaponKey);
if (weapon && weapon.weaponType !== character?.weaponType) {
throw new Error(`${weapon.name} ไม่รองรับ ${character?.name ?? "ตัวละครนี้"}`);
}
}
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.delete(teamCompositions).where(eq(teamCompositions.guideId, guideId));
for (const [sortOrder, team] of data.teams.entries()) {
const [created] = await tx.insert(teamCompositions).values({ guideId, title: team.title, rotationSeconds: team.rotationSeconds.toString(), combo: team.combo, sortOrder }).returning();
await tx.insert(teamMembers).values(team.members.map((member, position) => ({ ...member, teamId: created.id, position, damage: member.damage.toString() })));
}
return updated;
});
}
export async function saveExtraSections(guideId: string, input: unknown) {
const data = extraSectionSchema.parse(input);
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.delete(guideSections).where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra")));
for (const [sortOrder, section] of data.sections.entries()) {
const [createdSection] = await tx.insert(guideSections).values({ guideId, kind: "extra", slug: `extra-${sortOrder + 1}`, title: section.title, enabled: true, sortOrder: 4 + sortOrder }).returning();
await tx.insert(extraSections).values({ sectionId: createdSection.id });
if (section.rows.length) await tx.insert(extraSectionRows).values(section.rows.map((row, rowOrder) => ({ ...row, percent: row.percent?.toString() ?? null, sectionId: createdSection.id, sortOrder: rowOrder })));
}
return updated;
});
}
export async function trashGuide(guideId: string) {
return getDb().transaction(async (tx) => {
const [guide] = await tx.update(guides).set({ trashedAt: new Date(), version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
if (!guide) throw new Error("ไม่พบ Guide");
await emitGuideEvents(tx, guide.id, guide.version);
return guide;
});
}
export async function restoreGuide(guideId: string) {
return getDb().transaction(async (tx) => {
const [guide] = await tx.update(guides).set({ trashedAt: null, version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
if (!guide) throw new Error("ไม่พบ Guide");
await emitGuideEvents(tx, guide.id, guide.version);
return guide;
});
}
export async function permanentlyDeleteGuide(guideId: string) {
return getDb().transaction(async (tx) => {
const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร");
await tx.delete(guides).where(eq(guides.id, guideId));
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.coverMediaId));
await emitGuideEvents(tx, guide.id, guide.version + 1);
return { version: guide.version + 1 };
});
}
export async function runCatalogSync() {
await processCatalogSync();
return { status: "saved" as const };
}