feat : update
This commit is contained in:
+132
-1
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user