feat(guides) : add team transfer and visibility controls
This commit is contained in:
@@ -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
@@ -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>>>;
|
||||
|
||||
@@ -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("ไม่พบตัวละคร");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user