feat(guides) : add safer publishing and discovery
This commit is contained in:
+32
-32
@@ -31,7 +31,6 @@ import {
|
||||
constellationSectionSchema,
|
||||
createExtraSectionSchema,
|
||||
createGuideSchema,
|
||||
extraSectionSchema,
|
||||
reorderExtraSectionsSchema,
|
||||
saveExtraSectionSchema,
|
||||
slugFromCharacterName,
|
||||
@@ -39,7 +38,6 @@ import {
|
||||
updateGuideSchema,
|
||||
weaponSectionSchema,
|
||||
} from "@/lib/guides/schemas";
|
||||
import { processCatalogSync } from "@/lib/catalog/sync";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
import type { AdminEditorScope } from "@/lib/events/invalidation";
|
||||
|
||||
@@ -279,27 +277,6 @@ export async function saveTeams(guideId: string, input: unknown, details?: Guide
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveExtraSections(guideId: string, input: unknown) {
|
||||
const data = extraSectionSchema.parse(input);
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "extras:list");
|
||||
await tx.delete(guideSections).where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra")));
|
||||
for (const [sortOrder, section] of data.sections.entries()) {
|
||||
const [createdSection] = await tx.insert(guideSections).values({ guideId, kind: "extra", slug: `extra-${sortOrder + 1}`, title: section.title, enabled: true, sortOrder: 4 + sortOrder }).returning();
|
||||
await tx.insert(extraSections).values({ sectionId: createdSection.id });
|
||||
if (section.rows.length) await tx.insert(extraSectionRows).values(section.rows.map((row, rowOrder) => ({
|
||||
...row,
|
||||
body: normalizeExtraRichText(row.body, row.highlights),
|
||||
highlights: [],
|
||||
percent: row.percent?.toString() ?? null,
|
||||
sectionId: createdSection.id,
|
||||
sortOrder: rowOrder,
|
||||
})));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createExtraSection(guideId: string, input: unknown) {
|
||||
const data = createExtraSectionSchema.parse(input);
|
||||
return getDb().transaction(async (tx) => {
|
||||
@@ -447,14 +424,42 @@ export async function restoreGuide(guideId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function setGuideVisibility(guideId: string, isPublic: boolean) {
|
||||
export async function setGuideVisibility(
|
||||
guideId: string,
|
||||
input: {
|
||||
isPublic: boolean;
|
||||
expectedVersion: number;
|
||||
catalogVersion?: string;
|
||||
publicationNote?: string | null;
|
||||
},
|
||||
) {
|
||||
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`))
|
||||
.set({
|
||||
isPublic: input.isPublic,
|
||||
version: sql`${guides.version} + 1`,
|
||||
...(input.isPublic ? {
|
||||
publishedAt: new Date(),
|
||||
publishedCatalogVersion: input.catalogVersion,
|
||||
publicationNote: input.publicationNote ?? null,
|
||||
} : {}),
|
||||
})
|
||||
.where(and(
|
||||
eq(guides.id, guideId),
|
||||
eq(guides.version, input.expectedVersion),
|
||||
sql`${guides.trashedAt} is null`,
|
||||
))
|
||||
.returning();
|
||||
if (!guide) throw new Error("ไม่พบ Guide");
|
||||
if (!guide) {
|
||||
const [current] = await tx
|
||||
.select({ version: guides.version })
|
||||
.from(guides)
|
||||
.where(and(eq(guides.id, guideId), sql`${guides.trashedAt} is null`))
|
||||
.limit(1);
|
||||
if (!current) throw new Error("ไม่พบ Guide");
|
||||
throw new GuideVersionConflictError(current.version);
|
||||
}
|
||||
await emitGuideEvents(tx, guide.id, guide.version, true);
|
||||
return guide;
|
||||
});
|
||||
@@ -477,8 +482,3 @@ export async function permanentlyDeleteGuide(guideId: string) {
|
||||
return { version: guide.version + 1 };
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCatalogSync() {
|
||||
await processCatalogSync();
|
||||
return { status: "saved" as const };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PublicGuide } from "@/lib/guides/queries";
|
||||
|
||||
function hasPublicSectionContent(guide: PublicGuide, slug: string): boolean {
|
||||
if (slug === "weapon") return guide.weapons.length > 0;
|
||||
if (slug === "artifact") {
|
||||
return guide.artifactGroups.some((condition) =>
|
||||
condition.tiers.some((group) => group.rows.length > 0),
|
||||
) || Boolean(
|
||||
guide.artifactProfile && (
|
||||
guide.artifactProfile.recommendedConditions.some((condition) =>
|
||||
condition.sands.length || condition.goblet.length || condition.circlet.length || condition.substats.length,
|
||||
) ||
|
||||
guide.artifactProfile.sands.length ||
|
||||
guide.artifactProfile.goblet.length ||
|
||||
guide.artifactProfile.circlet.length ||
|
||||
guide.artifactProfile.substats.length ||
|
||||
guide.artifactProfile.targetConditions.some((group) => group.rows.length) ||
|
||||
guide.artifactProfile.talents.length
|
||||
),
|
||||
);
|
||||
}
|
||||
if (slug === "constellations") {
|
||||
return guide.constellationGroups.some((group) => group.rows.length > 0);
|
||||
}
|
||||
if (slug === "team") return guide.teams.length > 0;
|
||||
return guide.extras.some((extra) => extra.slug === slug && extra.rows.length > 0);
|
||||
}
|
||||
|
||||
export function publicGuideSections(guide: PublicGuide) {
|
||||
return guide.sections.filter((section) => hasPublicSectionContent(guide, section.slug));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { publicationIssues } from "@/lib/guides/publication";
|
||||
|
||||
const complete = {
|
||||
exists: true,
|
||||
overview: "Build overview",
|
||||
coverReady: true,
|
||||
catalogVersion: "6.0",
|
||||
renderableSectionCount: 1,
|
||||
version: 4,
|
||||
expectedVersion: 4,
|
||||
};
|
||||
|
||||
describe("publicationIssues", () => {
|
||||
it("accepts a complete persisted guide", () => {
|
||||
expect(publicationIssues(complete)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports every incomplete core requirement", () => {
|
||||
expect(publicationIssues({
|
||||
...complete,
|
||||
overview: " ",
|
||||
coverReady: false,
|
||||
catalogVersion: null,
|
||||
renderableSectionCount: 0,
|
||||
expectedVersion: 3,
|
||||
})).toEqual([
|
||||
"กรุณากรอก Overview",
|
||||
"รูปปกยังไม่พร้อมใช้งาน",
|
||||
"กรุณา Sync Catalog ก่อนเผยแพร่",
|
||||
"กรุณาเพิ่มเนื้อหาอย่างน้อยหนึ่ง Section",
|
||||
"Guide ถูกแก้ไขแล้ว กรุณาลองใหม่",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops at not found", () => {
|
||||
expect(publicationIssues({ ...complete, exists: false })).toEqual(["ไม่พบ Guide"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface PublicationCheckInput {
|
||||
exists: boolean;
|
||||
overview: string;
|
||||
coverReady: boolean;
|
||||
catalogVersion: string | null;
|
||||
renderableSectionCount: number;
|
||||
version: number;
|
||||
expectedVersion: number;
|
||||
}
|
||||
|
||||
export function publicationIssues(input: PublicationCheckInput): string[] {
|
||||
if (!input.exists) return ["ไม่พบ Guide"];
|
||||
const issues: string[] = [];
|
||||
if (!input.overview.trim()) issues.push("กรุณากรอก Overview");
|
||||
if (!input.coverReady) issues.push("รูปปกยังไม่พร้อมใช้งาน");
|
||||
if (!input.catalogVersion) issues.push("กรุณา Sync Catalog ก่อนเผยแพร่");
|
||||
if (input.renderableSectionCount === 0) issues.push("กรุณาเพิ่มเนื้อหาอย่างน้อยหนึ่ง Section");
|
||||
if (input.version !== input.expectedVersion) issues.push("Guide ถูกแก้ไขแล้ว กรุณาลองใหม่");
|
||||
return issues;
|
||||
}
|
||||
@@ -239,10 +239,17 @@ export async function listPublicGuideCards() {
|
||||
name: guides.name,
|
||||
overview: guides.overview,
|
||||
version: guides.version,
|
||||
updatedAt: guides.updatedAt,
|
||||
publishedAt: guides.publishedAt,
|
||||
publishedCatalogVersion: guides.publishedCatalogVersion,
|
||||
element: catalogCharacters.element,
|
||||
weaponType: catalogCharacters.weaponType,
|
||||
rarity: catalogCharacters.rarity,
|
||||
coverObjectKey: media.objectKey,
|
||||
})
|
||||
.from(guides)
|
||||
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
||||
.innerJoin(catalogCharacters, eq(catalogCharacters.key, guides.characterKey))
|
||||
.where(and(eq(guides.isPublic, true), isNull(guides.trashedAt)))
|
||||
.orderBy(desc(guides.sortOrder), desc(guides.createdAt), asc(guides.name)), getDb().select({
|
||||
kind: glossaryAliases.kind,
|
||||
@@ -307,6 +314,7 @@ export async function getGuideByCharacterKey(characterKey: string) {
|
||||
return {
|
||||
...row.guide,
|
||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||
coverStatus: row.cover.status,
|
||||
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
|
||||
posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, blendMode: (posterBlendModes.has(layer.blendMode) ? layer.blendMode : "multiply") as PosterBlendMode, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })),
|
||||
character: catalogCharacterWithUrls(row.character, materialByKey),
|
||||
@@ -399,6 +407,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
return {
|
||||
...row.guide,
|
||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||
coverStatus: row.cover.status,
|
||||
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
|
||||
posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, blendMode: (posterBlendModes.has(layer.blendMode) ? layer.blendMode : "multiply") as PosterBlendMode, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })),
|
||||
character: { ...catalogCharacterWithUrls(row.character, materialByKey), aliases: groupedAliases.character.get(row.character.key) ?? [] },
|
||||
@@ -449,4 +458,13 @@ export function getAdminGuideContent(slug: string) {
|
||||
return getGuideContent(slug, false);
|
||||
}
|
||||
|
||||
export async function getAdminGuideContentById(guideId: string) {
|
||||
const [guide] = await getDb()
|
||||
.select({ slug: guides.slug })
|
||||
.from(guides)
|
||||
.where(and(eq(guides.id, guideId), isNull(guides.trashedAt)))
|
||||
.limit(1);
|
||||
return guide ? getGuideContent(guide.slug, false) : null;
|
||||
}
|
||||
|
||||
export type PublicGuide = NonNullable<Awaited<ReturnType<typeof getPublicGuide>>>;
|
||||
|
||||
@@ -76,7 +76,3 @@ export function statPriorityColorClass(index: number): string {
|
||||
? "text-tier-copper"
|
||||
: "text-foreground";
|
||||
}
|
||||
|
||||
export function statPriorityColor(index: number): string {
|
||||
return index === 0 ? "#e0b35f" : index === 1 ? "#c7cbd3" : index === 2 ? "#bd8055" : "#ffffff";
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface DpsTeam {
|
||||
|
||||
export type DpsOrder = "best" | "worst" | null;
|
||||
|
||||
export function teamDps(team: DpsTeam): number {
|
||||
function teamDps(team: DpsTeam): number {
|
||||
const seconds = Number(team.rotationSeconds);
|
||||
if (!(seconds > 0)) return 0;
|
||||
return team.members.reduce((sum, member) => sum + Number(member.damage), 0) / seconds;
|
||||
|
||||
@@ -247,7 +247,7 @@ export function applyPresetFields(
|
||||
).slice(0, teams.length);
|
||||
}
|
||||
|
||||
export function presetFingerprint(team: Omit<TeamToolValue, "clientId">): string {
|
||||
function presetFingerprint(team: Omit<TeamToolValue, "clientId">): string {
|
||||
return JSON.stringify(team);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user