feat(guides) : add safer publishing and discovery
CI / Verify (push) Successful in 1m35s
CI / Build immutable images and deploy (push) Successful in 5m27s

This commit is contained in:
2026-09-19 13:32:57 +07:00 Unverified
parent 6698dc1302
commit 9896e8a4ad
27 changed files with 3213 additions and 108 deletions
+32 -32
View File
@@ -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 };
}
+31
View File
@@ -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));
}
+40
View File
@@ -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"]);
});
});
+20
View File
@@ -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;
}
+18
View File
@@ -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>>>;
-4
View File
@@ -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";
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
}