feat : update
CI / Verify (push) Successful in 1m16s
CI / Build immutable images and deploy (push) Successful in 1m58s

This commit is contained in:
2026-09-05 22:36:31 +07:00 Unverified
parent 66238795bf
commit 2de07c4917
12 changed files with 637 additions and 146 deletions
+132 -1
View File
@@ -1,6 +1,6 @@
import "server-only";
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import { getDb } from "@/db";
import {
@@ -26,8 +26,11 @@ import {
import {
artifactSectionSchema,
constellationSectionSchema,
createExtraSectionSchema,
createGuideSchema,
extraSectionSchema,
reorderExtraSectionsSchema,
saveExtraSectionSchema,
slugFromCharacterName,
teamSectionSchema,
updateGuideSchema,
@@ -222,6 +225,134 @@ export async function saveExtraSections(guideId: string, input: unknown) {
});
}
export async function createExtraSection(guideId: string, input: unknown) {
const data = createExtraSectionSchema.parse(input);
return getDb().transaction(async (tx) => {
const existing = await tx
.select({ slug: guideSections.slug, sortOrder: guideSections.sortOrder })
.from(guideSections)
.where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra")))
.orderBy(asc(guideSections.sortOrder));
if (existing.length >= 20) throw new Error("Guide มี Extra sections ได้สูงสุด 20 ส่วน");
const [updated] = await tx
.update(guides)
.set({ version: sql`${guides.version} + 1` })
.where(and(eq(guides.id, guideId), sql`${guides.trashedAt} is null`))
.returning();
if (!updated) throw new Error("ไม่พบ Guide");
const suffix = existing.reduce((highest, section) => {
const match = /^extra-(\d+)$/u.exec(section.slug);
return match ? Math.max(highest, Number(match[1])) : highest;
}, 0) + 1;
const sortOrder = Math.max(3, ...existing.map((section) => section.sortOrder)) + 1;
const [section] = await tx
.insert(guideSections)
.values({
guideId,
kind: "extra",
slug: `extra-${suffix}`,
title: data.title,
enabled: true,
sortOrder,
})
.returning();
await tx.insert(extraSections).values({ sectionId: section.id });
await emitGuideEvents(tx, guideId, updated.version);
return { ...section, version: updated.version };
});
}
export async function saveExtraSection(
guideId: string,
sectionId: string,
input: unknown,
) {
const data = saveExtraSectionSchema.parse(input);
return getDb().transaction(async (tx) => {
const [target] = await tx
.select({ id: guideSections.id })
.from(guideSections)
.where(and(
eq(guideSections.id, sectionId),
eq(guideSections.guideId, guideId),
eq(guideSections.kind, "extra"),
))
.limit(1);
if (!target) throw new Error("ไม่พบ Extra section");
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx
.update(guideSections)
.set({ title: data.section.title })
.where(eq(guideSections.id, sectionId));
await tx.delete(extraSectionRows).where(eq(extraSectionRows.sectionId, sectionId));
if (data.section.rows.length) {
await tx.insert(extraSectionRows).values(data.section.rows.map((row, sortOrder) => ({
...row,
body: normalizeExtraRichText(row.body, row.highlights),
highlights: [],
percent: row.percent?.toString() ?? null,
sectionId,
sortOrder,
})));
}
return updated;
});
}
export async function deleteExtraSection(guideId: string, sectionId: string) {
return getDb().transaction(async (tx) => {
const [target] = await tx
.select({ id: guideSections.id })
.from(guideSections)
.where(and(
eq(guideSections.id, sectionId),
eq(guideSections.guideId, guideId),
eq(guideSections.kind, "extra"),
))
.limit(1);
if (!target) throw new Error("ไม่พบ Extra section");
const [updated] = await tx
.update(guides)
.set({ version: sql`${guides.version} + 1` })
.where(and(eq(guides.id, guideId), sql`${guides.trashedAt} is null`))
.returning();
if (!updated) throw new Error("ไม่พบ Guide");
await tx.delete(guideSections).where(eq(guideSections.id, sectionId));
await emitGuideEvents(tx, guideId, updated.version);
return updated;
});
}
export async function reorderExtraSections(guideId: string, input: unknown) {
const data = reorderExtraSectionsSchema.parse(input);
return getDb().transaction(async (tx) => {
const existing = await tx
.select({ id: guideSections.id })
.from(guideSections)
.where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra")));
const existingIds = new Set(existing.map((section) => section.id));
if (
existingIds.size !== data.sectionIds.length ||
data.sectionIds.some((sectionId) => !existingIds.has(sectionId))
) {
throw new Error("รายการ Extra sections ไม่ตรงกับข้อมูลล่าสุด");
}
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
for (const [index, sectionId] of data.sectionIds.entries()) {
await tx
.update(guideSections)
.set({ sortOrder: 4 + index })
.where(eq(guideSections.id, sectionId));
}
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();
+1 -1
View File
@@ -173,7 +173,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
sections,
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })),
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], 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 }; }) })),
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, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, aliases: groupedAliases.artifact.get(second.key) ?? [], imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
artifactProfile: profile[0] ?? null,
constellationGroups: constellationGroups.map((group) => ({
...group,
+35 -1
View File
@@ -2,7 +2,18 @@ import { describe, expect, it } from "vitest";
import { calculateTeamMetrics } from "@/lib/guides/calculations";
import { createExtraSheet } from "@/lib/guides/extra-sheet";
import { artifactSectionSchema, constellationSectionSchema, createGuideSchema, extraSectionSchema, teamSectionSchema, updateGuideSchema, weaponSectionSchema } from "@/lib/guides/schemas";
import {
artifactSectionSchema,
constellationSectionSchema,
createExtraSectionSchema,
createGuideSchema,
extraSectionSchema,
reorderExtraSectionsSchema,
saveExtraSectionSchema,
teamSectionSchema,
updateGuideSchema,
weaponSectionSchema,
} from "@/lib/guides/schemas";
import {
recommendedStatElement,
recommendedStatIconName,
@@ -245,6 +256,29 @@ describe("structured guide validation", () => {
sections: [{ title: "Extra", rows: [{ ...row, sheet: invalidSheet }] }],
}).success).toBe(false);
});
it("validates targeted Extra create, save, and reorder inputs", () => {
const section = {
title: "Build notes",
rows: [{
mediaId: null,
heading: "Rotation",
body: "",
percent: null,
backgroundColor: null,
highlights: [],
catalogItems: [],
sheet: null,
}],
};
const firstId = "550e8400-e29b-41d4-a716-446655440000";
const secondId = "d9428888-122b-11e1-b85c-61cd3cbb3210";
expect(createExtraSectionSchema.parse({ title: " Build notes " }).title).toBe("Build notes");
expect(saveExtraSectionSchema.safeParse({ expectedVersion: 2, section }).success).toBe(true);
expect(reorderExtraSectionsSchema.safeParse({ expectedVersion: 2, sectionIds: [firstId, secondId] }).success).toBe(true);
expect(reorderExtraSectionsSchema.safeParse({ expectedVersion: 2, sectionIds: [firstId, firstId] }).success).toBe(false);
});
});
describe("Team Calc", () => {
+24 -4
View File
@@ -146,6 +146,7 @@ const extraRowSchema = z.strictObject({
body: z.string().trim().max(10_000),
percent: percent.nullable(),
backgroundColor: hexColor.nullable().default(null),
textColor: hexColor.nullable().default(null),
catalogItems: z.array(z.strictObject({
kind: z.enum(["character", "weapon", "artifact"]),
key: z.string().trim().min(1).max(64),
@@ -168,12 +169,31 @@ const extraRowSchema = z.strictObject({
highlights: [],
}));
export const extraSectionItemSchema = z.strictObject({
title: z.string().trim().min(1).max(160),
rows: z.array(extraRowSchema).max(50),
});
export const extraSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
sections: z.array(z.strictObject({
title: z.string().trim().min(1).max(160),
rows: z.array(extraRowSchema).max(50),
})).max(20),
sections: z.array(extraSectionItemSchema).max(20),
});
export const createExtraSectionSchema = z.strictObject({
title: z.string().trim().min(1).max(160),
});
export const saveExtraSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
section: extraSectionItemSchema,
});
export const reorderExtraSectionsSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
sectionIds: z.array(z.string().uuid()).max(20).refine(
(sectionIds) => new Set(sectionIds).size === sectionIds.length,
"Extra sections ต้องไม่ซ้ำกัน",
),
});
export type WeaponSectionInput = z.output<typeof weaponSectionSchema>;