feat(guides) : add team transfer and visibility controls
CI / Verify (push) Successful in 1m5s
CI / Build immutable images and deploy (push) Successful in 1m55s

This commit is contained in:
2026-09-04 19:46:46 +07:00 Unverified
parent 50a2a5428d
commit dd87510d68
15 changed files with 3972 additions and 19 deletions
+13
View File
@@ -232,6 +232,19 @@ export async function restoreGuide(guideId: string) {
});
}
export async function setGuideVisibility(guideId: string, isPublic: boolean) {
return getDb().transaction(async (tx) => {
const [guide] = await tx
.update(guides)
.set({ isPublic, version: sql`${guides.version} + 1` })
.where(and(eq(guides.id, guideId), sql`${guides.trashedAt} is null`))
.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);
+24 -2
View File
@@ -74,6 +74,16 @@ export async function listGuideCards(includeTrash = false) {
return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) }));
}
export async function listPublicGuideCards() {
const rows = await getDb()
.select({ guide: guides, cover: media })
.from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId))
.where(and(eq(guides.isPublic, true), isNull(guides.trashedAt)))
.orderBy(asc(guides.sortOrder), asc(guides.name));
return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) }));
}
export async function getGuideByCharacterKey(characterKey: string) {
const [row] = await getDb()
.select({ guide: guides, cover: media })
@@ -86,13 +96,17 @@ export async function getGuideByCharacterKey(characterKey: string) {
return { ...row.guide, coverUrl: publicMediaUrl(row.cover.objectKey), sections };
}
export async function getPublicGuide(slug: string) {
async function getGuideContent(slug: string, publicOnly: boolean) {
const [row] = await getDb()
.select({ guide: guides, cover: media, character: catalogCharacters })
.from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId))
.innerJoin(catalogCharacters, eq(catalogCharacters.key, guides.characterKey))
.where(and(eq(guides.slug, slug), isNull(guides.trashedAt)))
.where(and(
eq(guides.slug, slug),
isNull(guides.trashedAt),
...(publicOnly ? [eq(guides.isPublic, true)] : []),
))
.limit(1);
if (!row) return null;
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams] = await Promise.all([
@@ -162,4 +176,12 @@ export async function getPublicGuide(slug: string) {
};
}
export function getPublicGuide(slug: string) {
return getGuideContent(slug, true);
}
export function getAdminGuideContent(slug: string) {
return getGuideContent(slug, false);
}
export type PublicGuide = NonNullable<Awaited<ReturnType<typeof getPublicGuide>>>;
+99
View File
@@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest";
import {
applyTeamFields,
findInvalidTeamCatalogReference,
findUnsupportedTeamWeapon,
limitPresets,
parseTeamTransfer,
parseTeamPresets,
serializeTeamTransfer,
type TeamToolValue,
} from "./team-tools";
@@ -47,6 +50,50 @@ describe("team bulk tools", () => {
expect(result[1].members.slice(1)).toEqual(target.members.slice(1));
});
it("applies fields to the matching character in a different slot", () => {
const source = team("source");
const target = team("target");
source.members[1] = {
...source.members[1],
characterKey: "nicole",
weaponKey: "source-weapon",
};
target.members[3] = {
...target.members[3],
characterKey: "nicole",
weaponKey: "old-weapon",
};
const result = applyTeamFields(
[source, target],
"source",
new Set(["target"]),
["member:1:weaponKey"],
);
expect(result[1].members[3].weaponKey).toBe("source-weapon");
expect(result[1].members[1]).toEqual(target.members[1]);
});
it("falls back to the same slot when the character is not present", () => {
const source = team("source");
const target = team("target");
source.members[1] = {
...source.members[1],
characterKey: "nicole",
refinement: 5,
};
const result = applyTeamFields(
[source, target],
"source",
new Set(["target"]),
["member:1:refinement"],
);
expect(result[1].members[1].refinement).toBe(5);
});
it("detects an unsupported character and weapon after bulk apply", () => {
const target = team("target");
target.members[0] = {
@@ -74,4 +121,56 @@ describe("team bulk tools", () => {
expect(parseTeamPresets("not-json")).toEqual([]);
expect(parseTeamPresets(JSON.stringify([{ id: 1 }]))).toEqual([]);
});
it("round-trips every exported team field without the client id", () => {
const source = team("source", "Ineffa Exai");
source.rotationSeconds = "21.5";
source.members[0] = {
...source.members[0],
weaponKey: "weapon-1",
artifactKey: "artifact-1",
secondArtifactKey: "artifact-2",
constellation: 2,
refinement: 4,
damage: "123456.78",
};
expect(parseTeamTransfer(serializeTeamTransfer(source))).toEqual({
title: source.title,
rotationSeconds: source.rotationSeconds,
combo: source.combo,
members: source.members,
});
});
it("rejects malformed and unsupported team transfers", () => {
expect(() => parseTeamTransfer("not-json")).toThrow();
expect(() => parseTeamTransfer(JSON.stringify({
type: "buzz-sheet.team",
version: 2,
team: team("source"),
}))).toThrow();
});
it("reports missing and incompatible imported catalog references", () => {
const source = team("source");
source.members[0] = {
...source.members[0],
characterKey: "varka",
weaponKey: "wolf-fang",
};
const imported = { ...source, clientId: undefined as never };
const characters = [
{ key: "varka", name: "Varka", weaponType: "WEAPON_CLAYMORE" },
...source.members.slice(1).map((member) => ({ key: member.characterKey, name: member.characterKey })),
];
expect(findInvalidTeamCatalogReference(
imported,
characters,
[{ key: "wolf-fang", name: "Wolf-Fang", weaponType: "WEAPON_SWORD_ONE_HAND" }],
[],
)).toBe("Varka ไม่รองรับอาวุธ Wolf-Fang");
expect(findInvalidTeamCatalogReference(imported, [], [], [])).toContain("ไม่พบตัวละคร");
});
});
+91 -2
View File
@@ -1,4 +1,5 @@
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import * as z from "zod";
export interface TeamToolMember {
characterKey: string;
@@ -42,6 +43,87 @@ export interface TeamPreset {
team: Omit<TeamToolValue, "clientId">;
}
const transferMemberSchema = z.strictObject({
characterKey: z.string().trim().min(1).max(64),
weaponKey: z.string().trim().min(1).max(64).nullable(),
artifactKey: z.string().trim().min(1).max(64).nullable(),
secondArtifactKey: z.string().trim().min(1).max(64).nullable(),
constellation: z.number().int().min(0).max(6),
refinement: z.number().int().min(1).max(5),
damage: z.string().refine((value) => {
const number = Number(value);
return Number.isFinite(number) && number >= 0 && number <= 999_999_999_999;
}),
});
const transferTeamSchema = z.strictObject({
title: z.string().trim().min(1).max(160),
rotationSeconds: z.string().refine((value) => {
const number = Number(value);
return Number.isFinite(number) && number > 0 && number <= 9999;
}),
combo: z.string().trim().min(1).max(2_000),
members: z.array(transferMemberSchema).length(4)
.refine(
(members) => new Set(members.map((member) => member.characterKey)).size === 4,
)
.refine((members) => members.some((member) => Number(member.damage) > 0)),
});
const teamTransferSchema = z.strictObject({
type: z.literal("buzz-sheet.team"),
version: z.literal(1),
team: transferTeamSchema,
});
export function serializeTeamTransfer(team: TeamToolValue): string {
const payload = teamTransferSchema.parse({
type: "buzz-sheet.team",
version: 1,
team: {
title: team.title,
rotationSeconds: team.rotationSeconds,
combo: team.combo,
members: team.members,
},
});
return JSON.stringify(payload, null, 2);
}
export function parseTeamTransfer(serialized: string): Omit<TeamToolValue, "clientId"> {
const payload = teamTransferSchema.parse(JSON.parse(serialized) as unknown);
return {
...payload.team,
members: payload.team.members.map((member) => ({ ...member })),
};
}
export function findInvalidTeamCatalogReference(
team: Omit<TeamToolValue, "clientId">,
characters: readonly TeamToolCatalogItem[],
weapons: readonly TeamToolCatalogItem[],
artifacts: readonly TeamToolCatalogItem[],
): string | null {
const characterByKey = new Map(characters.map((item) => [item.key, item]));
const weaponByKey = new Map(weapons.map((item) => [item.key, item]));
const artifactByKey = new Map(artifacts.map((item) => [item.key, item]));
for (const member of team.members) {
const character = characterByKey.get(member.characterKey);
if (!character) return `ไม่พบตัวละคร ${member.characterKey} ใน Catalog`;
const weapon = member.weaponKey ? weaponByKey.get(member.weaponKey) : null;
if (member.weaponKey && !weapon) return `ไม่พบอาวุธ ${member.weaponKey} ใน Catalog`;
if (weapon && !supportsWeaponType(character.weaponType, weapon.weaponType)) {
return `${character.name} ไม่รองรับอาวุธ ${weapon.name}`;
}
for (const artifactKey of [member.artifactKey, member.secondArtifactKey]) {
if (artifactKey && !artifactByKey.has(artifactKey)) {
return `ไม่พบ Artifact ${artifactKey} ใน Catalog`;
}
}
}
return null;
}
interface TeamToolCatalogItem {
key: string;
name: string;
@@ -109,9 +191,16 @@ export function applyTeamFields(
const memberIndex = Number(match[1]);
const memberField = match[2] as TeamMemberApplyField;
const sourceMember = source.members[memberIndex];
const targetMember = next.members[memberIndex];
const matchingMemberIndex = sourceMember?.characterKey
? next.members.findIndex(
(member) => member.characterKey === sourceMember.characterKey,
)
: -1;
const targetMemberIndex =
matchingMemberIndex === -1 ? memberIndex : matchingMemberIndex;
const targetMember = next.members[targetMemberIndex];
if (!sourceMember || !targetMember || !teamMemberApplyFields.includes(memberField)) continue;
next.members[memberIndex] = {
next.members[targetMemberIndex] = {
...targetMember,
[memberField]: sourceMember[memberField],
};