feat : update

This commit is contained in:
2026-10-05 20:59:44 +07:00 Unverified
parent 3c3b104779
commit 08b61b5d91
15 changed files with 4398 additions and 62 deletions
+50 -4
View File
@@ -161,16 +161,36 @@ async function advanceGuideVersion(tx: Parameters<Parameters<ReturnType<typeof g
return guide;
}
async function syncConditionIconReferences(
tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0],
previous: Array<string | null | undefined>,
next: Array<string | null | undefined>,
) {
const nextIds = [...new Set(next.filter((id): id is string => Boolean(id)))];
if (nextIds.length) {
const ready = await tx.select({ id: media.id }).from(media).where(and(inArray(media.id, nextIds), eq(media.status, "ready")));
if (ready.length !== nextIds.length) throw new Error("ไม่พบภาพ Condition ที่อัปโหลดแล้ว");
}
const changes = new Map<string, number>();
for (const id of previous) if (id) changes.set(id, (changes.get(id) ?? 0) - 1);
for (const id of next) if (id) changes.set(id, (changes.get(id) ?? 0) + 1);
for (const [id, change] of changes) {
if (change) await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} + ${change}, 0)` }).where(eq(media.id, id));
}
}
export async function saveWeapons(guideId: string, input: unknown, context: GuideMutationContext) {
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, false, "weapon", invalidationDetails(context));
const previous = await tx.select({ iconMediaId: weaponConditions.iconMediaId }).from(weaponConditions).where(eq(weaponConditions.guideId, guideId));
await syncConditionIconReferences(tx, previous.map((group) => group.iconMediaId), data.groups.map((group) => group.iconMediaId));
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "weapon")));
await tx.delete(weaponConditions).where(eq(weaponConditions.guideId, guideId));
for (const [sortOrder, group] of data.groups.entries()) {
const [created] = await tx.insert(weaponConditions).values({ guideId, name: group.name, note: group.note, sortOrder }).returning();
const [created] = await tx.insert(weaponConditions).values({ guideId, name: group.name, note: group.note, iconMediaId: group.iconMediaId, sortOrder }).returning();
if (group.rows.length) await tx.insert(weaponRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, refinement: row.refinement, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, conditionId: created.id, sortOrder: rowOrder })));
}
await auditGuide(tx, context, "guide.weapon.saved", updated, "weapon");
@@ -259,9 +279,20 @@ export async function saveArtifacts(guideId: string, input: unknown, context: Gu
if (!guide) throw new Error("ไม่พบ Guide");
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "artifact", invalidationDetails(context));
const previousConditions = await tx.select({ iconMediaId: artifactConditions.iconMediaId }).from(artifactConditions).where(eq(artifactConditions.guideId, guideId));
const [previousProfile] = await tx.select({ recommendedConditions: artifactProfiles.recommendedConditions, targetConditions: artifactProfiles.targetConditions }).from(artifactProfiles).where(eq(artifactProfiles.guideId, guideId)).limit(1);
await syncConditionIconReferences(tx, [
...previousConditions.map((group) => group.iconMediaId),
...(previousProfile?.recommendedConditions ?? []).map((group) => group.iconMediaId),
...(previousProfile?.targetConditions ?? []).map((group) => group.iconMediaId),
], [
...data.groups.map((group) => group.iconMediaId),
...(data.recommendedGroups ?? []).map((group) => group.iconMediaId),
...data.targetGroups.map((group) => group.iconMediaId),
]);
await tx.delete(artifactConditions).where(eq(artifactConditions.guideId, guideId));
for (const [conditionOrder, condition] of data.groups.entries()) {
const [createdCondition] = await tx.insert(artifactConditions).values({ guideId, name: condition.name, note: condition.note, sortOrder: conditionOrder }).returning();
const [createdCondition] = await tx.insert(artifactConditions).values({ guideId, name: condition.name, note: condition.note, iconMediaId: condition.iconMediaId, sortOrder: conditionOrder }).returning();
for (const [sortOrder, group] of condition.tiers.entries()) {
const [created] = await tx.insert(artifactGroups).values({ conditionId: createdCondition.id, 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 })));
@@ -277,10 +308,12 @@ export async function saveConstellations(guideId: string, input: unknown, contex
const data = constellationSectionSchema.parse(input);
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "constellations", invalidationDetails(context));
const previous = await tx.select({ iconMediaId: constellationConditions.iconMediaId }).from(constellationConditions).where(eq(constellationConditions.guideId, guideId));
await syncConditionIconReferences(tx, previous.map((group) => group.iconMediaId), data.groups.map((group) => group.iconMediaId));
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, name: group.name, note: group.note, sortOrder }).returning();
const [created] = await tx.insert(constellationConditions).values({ guideId, name: group.name, note: group.note, iconMediaId: group.iconMediaId, sortOrder }).returning();
if (group.rows.length) await tx.insert(constellationRecommendations).values(group.rows.map((row) => ({ ...row, cumulativePercent: row.cumulativePercent.toString(), conditionId: created.id })));
}
await auditGuide(tx, context, "guide.constellations.saved", updated, "constellations");
@@ -313,9 +346,11 @@ export async function saveTeams(guideId: string, input: unknown, context: GuideM
}
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "team", invalidationDetails(context));
const previous = await tx.select({ iconMediaId: teamConditions.iconMediaId }).from(teamConditions).where(eq(teamConditions.guideId, guideId));
await syncConditionIconReferences(tx, previous.map((group) => group.iconMediaId), data.groups.map((group) => group.iconMediaId));
await tx.delete(teamConditions).where(eq(teamConditions.guideId, guideId));
for (const [groupOrder, group] of data.groups.entries()) {
const [condition] = await tx.insert(teamConditions).values({ guideId, name: group.name, note: group.note, sortOrder: groupOrder }).returning();
const [condition] = await tx.insert(teamConditions).values({ guideId, name: group.name, note: group.note, iconMediaId: group.iconMediaId, sortOrder: groupOrder }).returning();
for (const [sortOrder, team] of group.teams.entries()) {
const [created] = await tx.insert(teamCompositions).values({ guideId, conditionId: condition.id, 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() })));
@@ -583,7 +618,18 @@ export async function permanentlyDeleteGuide(
const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร");
const bleeding = await tx.select({ mediaId: guidePosterBleeding.mediaId }).from(guidePosterBleeding).where(eq(guidePosterBleeding.guideId, guideId));
const weapons = await tx.select({ iconMediaId: weaponConditions.iconMediaId }).from(weaponConditions).where(eq(weaponConditions.guideId, guideId));
const artifacts = await tx.select({ iconMediaId: artifactConditions.iconMediaId }).from(artifactConditions).where(eq(artifactConditions.guideId, guideId));
const constellations = await tx.select({ iconMediaId: constellationConditions.iconMediaId }).from(constellationConditions).where(eq(constellationConditions.guideId, guideId));
const teams = await tx.select({ iconMediaId: teamConditions.iconMediaId }).from(teamConditions).where(eq(teamConditions.guideId, guideId));
const profiles = await tx.select({ recommendedConditions: artifactProfiles.recommendedConditions, targetConditions: artifactProfiles.targetConditions }).from(artifactProfiles).where(eq(artifactProfiles.guideId, guideId));
const conditionIconIds = [
...weapons, ...artifacts, ...constellations, ...teams,
...(profiles[0]?.recommendedConditions ?? []),
...(profiles[0]?.targetConditions ?? []),
].map((condition) => condition.iconMediaId);
await tx.delete(guides).where(eq(guides.id, guideId));
await syncConditionIconReferences(tx, conditionIconIds, []);
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.coverMediaId));
if (guide.posterArtMediaId) {
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.posterArtMediaId));
+25 -2
View File
@@ -25,9 +25,9 @@ vi.mock("@/db", () => ({
}));
import {
artifactConditions, constellationConditions, extraSectionRows,
artifactConditions, artifactProfiles, constellationConditions, extraSectionRows,
guides, guideSections, teamConditions, weaponConditions,
catalogMaterials, guidePosterBleeding,
catalogMaterials, guidePosterBleeding, media,
} from "@/db/schema";
import { getAdminGuideContent } from "./queries";
@@ -78,4 +78,27 @@ describe("admin section reads", () => {
}
expect(result?.extras).toHaveLength(2);
});
it("resolves condition icons from one batched media read", async () => {
const iconMediaId = "550e8400-e29b-41d4-a716-446655440000";
mocks.rows.set(weaponConditions, [{ id: "weapon-condition", iconMediaId }]);
mocks.rows.set(artifactConditions, [{ id: "artifact-condition", iconMediaId }]);
mocks.rows.set(constellationConditions, [{ id: "constellation-condition", iconMediaId }]);
mocks.rows.set(teamConditions, [{ id: "team-condition", iconMediaId }]);
mocks.rows.set(artifactProfiles, [{
sands: [], goblet: [], circlet: [], substats: [], targets: [],
recommendedConditions: [{ name: "Recommended", note: null, iconMediaId, sands: [], goblet: [], circlet: [], substats: [] }],
targetConditions: [{ name: "Target", note: null, iconMediaId, rows: [] }],
}]);
mocks.rows.set(media, [{ id: iconMediaId, objectKey: "condition-icon.png" }]);
const result = await getAdminGuideContent("test");
expect(result?.weaponGroups[0].iconUrl).toBe("/media/condition-icon.png");
expect(result?.artifactGroups[0].iconUrl).toBe("/media/condition-icon.png");
expect(result?.constellationGroups[0].iconUrl).toBe("/media/condition-icon.png");
expect(result?.teamGroups[0].iconUrl).toBe("/media/condition-icon.png");
expect(result?.artifactProfile?.recommendedConditions[0].iconUrl).toBe("/media/condition-icon.png");
expect(result?.artifactProfile?.targetConditions[0].iconUrl).toBe("/media/condition-icon.png");
expect(mocks.reads.filter((table) => table === media)).toHaveLength(1);
});
});
+13 -4
View File
@@ -425,7 +425,15 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio
: Promise.resolve([]),
() => wants("overview") ? getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)) : Promise.resolve([]),
]);
const iconIds = [...new Set([
...weaponConditionsRows, ...artifactConditionRows, ...constellationGroups, ...teamConditionRows,
...(profile[0]?.recommendedConditions ?? []), ...(profile[0]?.targetConditions ?? []),
].map((condition) => condition.iconMediaId).filter((id): id is string => Boolean(id)))];
const iconAssetsPromise = iconIds.length ? getDb().select({ id: media.id, objectKey: media.objectKey }).from(media).where(and(inArray(media.id, iconIds), eq(media.status, "ready"))) : Promise.resolve([]);
const groups = artifactConditionRows.length ? await getDb().select().from(artifactGroups).where(inArray(artifactGroups.conditionId, artifactConditionRows.map((condition) => condition.id))).orderBy(asc(artifactGroups.sortOrder)) : [];
const iconAssets = await iconAssetsPromise;
const iconUrls = new Map(iconAssets.map((asset) => [asset.id, publicMediaUrl(asset.objectKey)]));
const iconUrl = (id: string | null | undefined) => id ? iconUrls.get(id) ?? null : null;
const groupedAliases = aliasesByItem(aliases);
const materialByKey = new Map(materials.map((item) => [item.key, item]));
@@ -485,11 +493,12 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio
sections,
availableSections,
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: artifactConditionRows.map((condition) => ({ ...condition, tiers: groups.filter((group) => group.conditionId === condition.id).map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifactByKey.get(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] ? { ...profile[0], recommendedConditions: profile[0].recommendedConditions.length ? profile[0].recommendedConditions : [{ name: null, note: null, sands: profile[0].sands, goblet: profile[0].goblet, circlet: profile[0].circlet, substats: profile[0].substats }], targetConditions: profile[0].targetConditions.length ? profile[0].targetConditions : [{ name: null, note: null, rows: profile[0].targets }] } : null,
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, iconUrl: iconUrl(condition.iconMediaId), 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: artifactConditionRows.map((condition) => ({ ...condition, iconUrl: iconUrl(condition.iconMediaId), tiers: groups.filter((group) => group.conditionId === condition.id).map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifactByKey.get(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] ? { ...profile[0], recommendedConditions: (profile[0].recommendedConditions.length ? profile[0].recommendedConditions : [{ name: null, note: null, iconMediaId: null, sands: profile[0].sands, goblet: profile[0].goblet, circlet: profile[0].circlet, substats: profile[0].substats }]).map((condition) => ({ ...condition, iconMediaId: condition.iconMediaId ?? null, iconUrl: iconUrl(condition.iconMediaId) })), targetConditions: (profile[0].targetConditions.length ? profile[0].targetConditions : [{ name: null, note: null, iconMediaId: null, rows: profile[0].targets }]).map((condition) => ({ ...condition, iconMediaId: condition.iconMediaId ?? null, iconUrl: iconUrl(condition.iconMediaId) })) } : null,
constellationGroups: constellationGroups.map((group) => ({
...group,
iconUrl: iconUrl(group.iconMediaId),
rows: constellations.filter((recommendation) => recommendation.conditionId === group.id).map((recommendation) => {
const catalog = row.character.constellations.find((item) => item.level === recommendation.level);
return {
@@ -502,7 +511,7 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio
}),
})),
teams: hydratedTeams,
teamGroups: teamConditionRows.length ? teamConditionRows.map((condition) => ({ ...condition, teams: hydratedTeams.filter((team) => team.conditionId === condition.id) })) : [{ id: "team-condition-default", guideId: row.guide.id, name: null, note: null, sortOrder: 0, teams: hydratedTeams }],
teamGroups: teamConditionRows.length ? teamConditionRows.map((condition) => ({ ...condition, iconUrl: iconUrl(condition.iconMediaId), teams: hydratedTeams.filter((team) => team.conditionId === condition.id) })) : [{ id: "team-condition-default", guideId: row.guide.id, name: null, note: null, iconMediaId: null, iconUrl: null, sortOrder: 0, teams: hydratedTeams }],
extras: sections.filter((section) => section.kind === "extra" && wants(section.slug)).map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({
...extraRow,
catalogItems: extraRow.catalogItems.map((reference) => {
+10
View File
@@ -78,6 +78,16 @@ describe("structured guide validation", () => {
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ name: "x".repeat(121), note: null, rows: [row] }] }).success).toBe(false);
});
it("accepts optional condition icons and rejects invalid media IDs", () => {
const iconMediaId = "550e8400-e29b-41d4-a716-446655440000";
const weapon = { expectedVersion: 1, note: null, groups: [{ name: "DPS", note: null, iconMediaId, rows: [] }] };
expect(weaponSectionSchema.parse(weapon).groups[0].iconMediaId).toBe(iconMediaId);
expect(weaponSectionSchema.parse({ ...weapon, groups: [{ name: null, note: null, rows: [] }] }).groups[0].iconMediaId).toBeNull();
expect(weaponSectionSchema.safeParse({ ...weapon, groups: [{ ...weapon.groups[0], iconMediaId: "bad" }] }).success).toBe(false);
expect(constellationSectionSchema.parse({ expectedVersion: 1, note: null, groups: [{ name: "C1", note: null, iconMediaId, rows: [] }] }).groups[0].iconMediaId).toBe(iconMediaId);
expect(teamSectionSchema.parse({ expectedVersion: 1, groups: [{ name: "Team", note: null, iconMediaId, teams: [] }] }).groups[0].iconMediaId).toBe(iconMediaId);
});
it("limits weapon and constellation header notes", () => {
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", groups: [] }).success).toBe(true);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), groups: [] }).success).toBe(false);
+7
View File
@@ -10,6 +10,7 @@ export type GuideSectionKind = (typeof guideSectionKinds)[number];
const percent = z.coerce.number().finite().min(-9999).max(9999);
const conditionName = z.string().trim().max(120).nullable().optional().transform((value) => value || null);
const conditionIconMediaId = z.string().uuid().nullable().optional().transform((value) => value ?? null);
const catalogKey = z.string().trim().min(1).max(64);
export const weaponRefinementSchema = z.union([
z.number().int().min(1).max(5).transform(String),
@@ -56,6 +57,7 @@ export const weaponSectionSchema = z.strictObject({
groups: z.array(z.strictObject({
name: conditionName,
note: z.string().trim().max(2_000).nullable(),
iconMediaId: conditionIconMediaId,
rows: z.array(z.strictObject({
weaponKey: catalogKey,
refinement: weaponRefinementSchema,
@@ -93,6 +95,7 @@ export const artifactSectionSchema = z.strictObject({
groups: z.array(z.strictObject({
name: conditionName,
note: z.string().trim().max(2_000).nullable(),
iconMediaId: conditionIconMediaId,
tiers: z.tuple([
artifactGroupSchema("Late Game"),
artifactGroupSchema("Early Game"),
@@ -105,6 +108,7 @@ export const artifactSectionSchema = z.strictObject({
recommendedGroups: z.array(z.strictObject({
name: conditionName,
note: z.string().trim().max(2_000).nullable(),
iconMediaId: conditionIconMediaId,
sands: z.array(z.enum(mainStatOptions.sands)),
goblet: z.array(z.enum(mainStatOptions.goblet)),
circlet: z.array(z.enum(mainStatOptions.circlet)),
@@ -113,6 +117,7 @@ export const artifactSectionSchema = z.strictObject({
targetGroups: z.array(z.strictObject({
name: conditionName,
note: z.string().trim().max(2_000).nullable(),
iconMediaId: conditionIconMediaId,
rows: z.array(z.strictObject({
stat: z.enum(targetStatOptions),
value: z.string().trim().min(1).max(80),
@@ -142,6 +147,7 @@ export const constellationSectionSchema = z.strictObject({
groups: z.array(z.strictObject({
name: conditionName,
note: z.string().trim().max(2_000).nullable(),
iconMediaId: conditionIconMediaId,
rows: constellationRowsSchema,
})).max(20),
});
@@ -151,6 +157,7 @@ export const teamSectionSchema = z.strictObject({
groups: z.array(z.strictObject({
name: conditionName,
note: z.string().trim().max(2_000).nullable(),
iconMediaId: conditionIconMediaId,
teams: z.array(z.strictObject({
title: z.string().trim().min(1).max(160),
rotationSeconds: z.coerce.number().finite().positive().max(9999),