feat(admin) : persist drafts and add constellation conditions
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { adminDraftKey, parseLocalDraft, serializeLocalDraft } from "./local-draft";
|
||||
|
||||
describe("local admin drafts", () => {
|
||||
it("isolates drafts by guide and section", () => {
|
||||
expect(adminDraftKey("guide-1", "team")).toBe("buzz-sheet:admin-draft:guide-1:team");
|
||||
expect(adminDraftKey("guide-1", "weapon")).not.toBe(adminDraftKey("guide-1", "team"));
|
||||
expect(adminDraftKey("guide-2", "team")).not.toBe(adminDraftKey("guide-1", "team"));
|
||||
});
|
||||
|
||||
it("round trips a versioned draft", () => {
|
||||
const serialized = serializeLocalDraft({ title: "Recovered team" }, 7);
|
||||
const parsed = parseLocalDraft(serialized, (value) => {
|
||||
if (!value || typeof value !== "object" || typeof (value as { title?: unknown }).title !== "string") return null;
|
||||
return value as { title: string };
|
||||
});
|
||||
expect(parsed).toMatchObject({ schemaVersion: 1, serverVersion: 7, draft: { title: "Recovered team" } });
|
||||
});
|
||||
|
||||
it("rejects corrupt, incompatible, and invalid drafts", () => {
|
||||
const parse = (value: unknown) => typeof value === "string" ? value : null;
|
||||
expect(parseLocalDraft("not-json", parse)).toBeNull();
|
||||
expect(parseLocalDraft(JSON.stringify({ schemaVersion: 2, serverVersion: 1, updatedAt: "now", draft: "x" }), parse)).toBeNull();
|
||||
expect(parseLocalDraft(JSON.stringify({ schemaVersion: 1, serverVersion: 1, updatedAt: "now", draft: 5 }), parse)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export const LOCAL_DRAFT_SCHEMA_VERSION = 1;
|
||||
|
||||
export interface LocalDraftEnvelope<Draft> {
|
||||
schemaVersion: typeof LOCAL_DRAFT_SCHEMA_VERSION;
|
||||
serverVersion: number;
|
||||
updatedAt: string;
|
||||
draft: Draft;
|
||||
}
|
||||
|
||||
export function adminDraftKey(guideId: string, section: string): string {
|
||||
return `buzz-sheet:admin-draft:${guideId}:${section}`;
|
||||
}
|
||||
|
||||
export function serializeLocalDraft<Draft>(draft: Draft, serverVersion: number): string {
|
||||
return JSON.stringify({
|
||||
schemaVersion: LOCAL_DRAFT_SCHEMA_VERSION,
|
||||
serverVersion,
|
||||
updatedAt: new Date().toISOString(),
|
||||
draft,
|
||||
} satisfies LocalDraftEnvelope<Draft>);
|
||||
}
|
||||
|
||||
export function parseLocalDraft<Draft>(serialized: string, parseDraft: (value: unknown) => Draft | null): LocalDraftEnvelope<Draft> | null {
|
||||
try {
|
||||
const value: unknown = JSON.parse(serialized);
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const envelope = value as Record<string, unknown>;
|
||||
if (envelope.schemaVersion !== LOCAL_DRAFT_SCHEMA_VERSION || typeof envelope.serverVersion !== "number" || typeof envelope.updatedAt !== "string") return null;
|
||||
const draft = parseDraft(envelope.draft);
|
||||
return draft === null ? null : {
|
||||
schemaVersion: LOCAL_DRAFT_SCHEMA_VERSION,
|
||||
serverVersion: envelope.serverVersion,
|
||||
updatedAt: envelope.updatedAt,
|
||||
draft,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -10,6 +10,7 @@ import {
|
||||
catalogArtifacts,
|
||||
catalogCharacters,
|
||||
catalogWeapons,
|
||||
constellationConditions,
|
||||
constellationRecommendations,
|
||||
extraSectionRows,
|
||||
extraSections,
|
||||
@@ -40,6 +41,13 @@ const DEFAULT_SECTIONS = [
|
||||
{ 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 } },
|
||||
@@ -81,7 +89,11 @@ export async function createGuide(input: unknown) {
|
||||
|
||||
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) throw new Error("ข้อมูลถูกแก้ไขจากแท็บอื่น กรุณาโหลดหน้าใหม่");
|
||||
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;
|
||||
}
|
||||
@@ -137,8 +149,11 @@ export async function saveConstellations(guideId: string, input: unknown) {
|
||||
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(constellationRecommendations).where(eq(constellationRecommendations.guideId, guideId));
|
||||
if (data.rows.length) await tx.insert(constellationRecommendations).values(data.rows.map((row) => ({ ...row, cumulativePercent: row.cumulativePercent.toString(), guideId })));
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
+16
-11
@@ -10,6 +10,7 @@ import {
|
||||
catalogArtifacts,
|
||||
catalogCharacters,
|
||||
catalogWeapons,
|
||||
constellationConditions,
|
||||
constellationRecommendations,
|
||||
extraSectionRows,
|
||||
guides,
|
||||
@@ -85,15 +86,16 @@ export async function getPublicGuide(slug: string) {
|
||||
.where(and(eq(guides.slug, slug), isNull(guides.trashedAt)))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const [sections, weaponRows, groups, profile, constellations, teams] = await Promise.all([
|
||||
const [sections, weaponRows, groups, profile, constellationGroups, teams] = await Promise.all([
|
||||
getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
|
||||
getDb().select({ recommendation: weaponRecommendations, weapon: catalogWeapons }).from(weaponRecommendations).innerJoin(catalogWeapons, eq(catalogWeapons.key, weaponRecommendations.weaponKey)).where(eq(weaponRecommendations.guideId, row.guide.id)).orderBy(asc(weaponRecommendations.sortOrder)),
|
||||
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
|
||||
getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1),
|
||||
getDb().select().from(constellationRecommendations).where(eq(constellationRecommendations.guideId, row.guide.id)).orderBy(asc(constellationRecommendations.level)),
|
||||
getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)),
|
||||
getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
|
||||
]);
|
||||
|
||||
const constellations = constellationGroups.length ? await getDb().select().from(constellationRecommendations).where(inArray(constellationRecommendations.conditionId, constellationGroups.map((group) => group.id))).orderBy(asc(constellationRecommendations.level)) : [];
|
||||
const artifactRows = groups.length ? await getDb().select({ recommendation: artifactRecommendations, artifact: catalogArtifacts }).from(artifactRecommendations).innerJoin(catalogArtifacts, eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey)).where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id))).orderBy(asc(artifactRecommendations.sortOrder)) : [];
|
||||
const secondKeys = artifactRows.map(({ recommendation }) => recommendation.secondArtifactKey).filter((key): key is string => Boolean(key));
|
||||
const secondArtifacts = secondKeys.length ? await getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, secondKeys)) : [];
|
||||
@@ -115,15 +117,18 @@ export async function getPublicGuide(slug: string) {
|
||||
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })),
|
||||
artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
|
||||
artifactProfile: profile[0] ?? null,
|
||||
constellations: constellations.map((recommendation) => {
|
||||
const catalog = row.character.constellations.find((item) => item.level === recommendation.level);
|
||||
return {
|
||||
...recommendation,
|
||||
name: catalog?.name ?? `C${recommendation.level}`,
|
||||
detail: catalog?.description ?? recommendation.detail,
|
||||
iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null,
|
||||
};
|
||||
}),
|
||||
constellationGroups: constellationGroups.map((group) => ({
|
||||
...group,
|
||||
rows: constellations.filter((recommendation) => recommendation.conditionId === group.id).map((recommendation) => {
|
||||
const catalog = row.character.constellations.find((item) => item.level === recommendation.level);
|
||||
return {
|
||||
...recommendation,
|
||||
name: catalog?.name ?? `C${recommendation.level}`,
|
||||
detail: catalog?.description ?? recommendation.detail,
|
||||
iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: catalogCharacterWithUrls(character), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
|
||||
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({ ...extraRow, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })) })),
|
||||
};
|
||||
|
||||
@@ -27,8 +27,8 @@ describe("structured guide validation", () => {
|
||||
it("limits weapon and constellation header notes", () => {
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", rows: [] }).success).toBe(true);
|
||||
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), rows: [] }).success).toBe(false);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", rows: [] }).success).toBe(true);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), rows: [] }).success).toBe(false);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", groups: [] }).success).toBe(true);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), groups: [] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("limits artifact main stats to three per slot", () => {
|
||||
@@ -88,8 +88,14 @@ describe("structured guide validation", () => {
|
||||
|
||||
it("persists whether a constellation percentage is marked as great", () => {
|
||||
const row = { level: 1, detail: "Test constellation", cumulativePercent: 120, isGreat: true };
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [row] }).success).toBe(true);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ ...row, isGreat: "yes" }] }).success).toBe(false);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: "Signature weapon", rows: [row] }] }).success).toBe(true);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ ...row, isGreat: "yes" }] }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("allows the same constellation level across conditions but not twice within one condition", () => {
|
||||
const row = { level: 2, detail: "Test constellation", cumulativePercent: 130, isGreat: false };
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: "A", rows: [row] }, { note: "B", rows: [row] }] }).success).toBe(true);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [row, row] }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires four unique team members and non-zero damage", () => {
|
||||
|
||||
+13
-6
@@ -77,15 +77,22 @@ export const artifactSectionSchema = z.strictObject({
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
});
|
||||
|
||||
const constellationRowsSchema = z.array(z.strictObject({
|
||||
level: z.number().int().min(1).max(6),
|
||||
detail: z.string().trim().min(1).max(2_000),
|
||||
cumulativePercent: percent,
|
||||
isGreat: z.boolean(),
|
||||
})).max(6).refine((rows) => new Set(rows.map((row) => row.level)).size === rows.length, {
|
||||
message: "แต่ละเงื่อนไขต้องมีระดับกลุ่มดาวไม่ซ้ำกัน",
|
||||
});
|
||||
|
||||
export const constellationSectionSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
rows: z.array(z.strictObject({
|
||||
level: z.number().int().min(1).max(6),
|
||||
detail: z.string().trim().min(1).max(2_000),
|
||||
cumulativePercent: percent,
|
||||
isGreat: z.boolean(),
|
||||
})).max(6),
|
||||
groups: z.array(z.strictObject({
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
rows: constellationRowsSchema,
|
||||
})).max(20),
|
||||
});
|
||||
|
||||
export const teamSectionSchema = z.strictObject({
|
||||
|
||||
Reference in New Issue
Block a user