import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import { closeDb, getDb } from "@/db/client"; import { artifactGroups, artifactProfiles, artifactRecommendations, catalogArtifacts, catalogCharacters, catalogWeapons, constellationConditions, constellationRecommendations, extraSectionRows, extraSections, guides, guideSections, media, outboxEvents, teamCompositions, teamMembers, weaponRecommendations, } from "@/db/schema"; import { artifactSectionSchema, constellationSectionSchema, extraSectionSchema, slugFromCharacterName, teamSectionSchema, weaponSectionSchema, } from "@/lib/guides/schemas"; import { buildGuideImportDraft, guideImportSources, type GuideImportDraft, type ImportCatalog, } from "@/lib/guides/google-sheet-import"; import { getMediaStorage } from "@/lib/media/storage"; import { hasValidImageSignature, MAX_MEDIA_BYTES } from "@/lib/media/validation"; const apply = process.argv.includes("--apply"); interface CoverAsset { bytes: Uint8Array; extension: "gif" | "jpeg" | "png" | "webp"; mimeType: "image/gif" | "image/jpeg" | "image/png" | "image/webp"; objectKey: string; } function coverType(bytes: Uint8Array): Pick | null { const types = [ ["png", "image/png"], ["jpeg", "image/jpeg"], ["webp", "image/webp"], ["gif", "image/gif"], ] as const; for (const [extension, mimeType] of types) { if (hasValidImageSignature(bytes.subarray(0, 16), mimeType)) return { extension, mimeType }; } return null; } async function downloadCover(draft: GuideImportDraft): Promise { const response = await fetch(`https://buzz.sudloh.com/cdn/${draft.source.coverId}`, { signal: AbortSignal.timeout(30_000), }); if (!response.ok) throw new Error(`${draft.source.characterName}: cover returned ${response.status}`); const bytes = new Uint8Array(await response.arrayBuffer()); if (!bytes.length || bytes.length > MAX_MEDIA_BYTES) throw new Error(`${draft.source.characterName}: invalid cover size ${bytes.length}`); const type = coverType(bytes); if (!type) throw new Error(`${draft.source.characterName}: unsupported cover image`); return { ...type, bytes, objectKey: `imports/buzz-guide/${draft.characterKey}/cover.${type.extension}`, }; } function validateDraft(draft: GuideImportDraft): void { weaponSectionSchema.parse({ expectedVersion: 1, note: null, rows: draft.weapons }); artifactSectionSchema.parse({ expectedVersion: 1, ...draft.artifacts }); constellationSectionSchema.parse({ expectedVersion: 1, note: null, groups: draft.constellationGroups }); teamSectionSchema.parse({ expectedVersion: 1, teams: draft.teams }); extraSectionSchema.parse({ expectedVersion: 1, sections: draft.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ ...row, mediaId: null, ratio: "16:9" as const })), })), }); } function reportFor(draft: GuideImportDraft) { return { character: draft.source.characterName, action: draft.source.skipWrite ? "keep-existing" : draft.source.characterName === "Columbina" ? "restore-or-update" : "create-or-update", weapons: draft.weapons.length, artifacts: draft.artifacts.groups.reduce((total, group) => total + group.rows.length, 0), constellations: draft.constellationGroups.reduce((total, group) => total + group.rows.length, 0), teams: draft.teams.length, extras: draft.extras.reduce((total, section) => total + section.rows.length, 0), skipped: [...new Set(draft.skipped)], }; } async function loadCatalog(): Promise { const db = getDb(); const [characters, weapons, artifacts] = await Promise.all([ db.select({ key: catalogCharacters.key, name: catalogCharacters.name, constellations: catalogCharacters.constellations }).from(catalogCharacters), db.select({ key: catalogWeapons.key, name: catalogWeapons.name }).from(catalogWeapons), db.select({ key: catalogArtifacts.key, name: catalogArtifacts.name }).from(catalogArtifacts), ]); return { characters, weapons, artifacts }; } async function uniqueSlug(tx: Parameters["transaction"]>[0]>[0], name: string, key: string): Promise { const base = slugFromCharacterName(name, key); const [collision] = await tx.select({ id: guides.id }).from(guides).where(eq(guides.slug, base)).limit(1); return collision ? `${base}-${key.toLowerCase()}` : base; } async function replaceGuide( tx: Parameters["transaction"]>[0]>[0], draft: GuideImportDraft, coverMediaId: string, sortOrder: number, ): Promise { const [catalogCharacter] = await tx.select().from(catalogCharacters).where(eq(catalogCharacters.key, draft.characterKey)).limit(1); if (!catalogCharacter) throw new Error(`${draft.source.characterName}: character disappeared from catalog`); const [existing] = await tx.select().from(guides).where(eq(guides.characterKey, draft.characterKey)).limit(1); let guideId: string; let version: number; if (existing) { guideId = existing.id; version = existing.version + 1; await tx.update(guides).set({ overview: draft.source.overview, coverMediaId, sortOrder, trashedAt: null, version, }).where(eq(guides.id, guideId)); if (existing.coverMediaId !== coverMediaId) { await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, existing.coverMediaId)); await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, coverMediaId)); } } else { const slug = await uniqueSlug(tx, catalogCharacter.name, catalogCharacter.key); const [created] = await tx.insert(guides).values({ characterKey: catalogCharacter.key, slug, name: catalogCharacter.name, overview: draft.source.overview, coverMediaId, sortOrder, }).returning(); guideId = created.id; version = created.version; await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, coverMediaId)); } await Promise.all([ tx.delete(weaponRecommendations).where(eq(weaponRecommendations.guideId, guideId)), tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId)), tx.delete(artifactProfiles).where(eq(artifactProfiles.guideId, guideId)), tx.delete(constellationConditions).where(eq(constellationConditions.guideId, guideId)), tx.delete(teamCompositions).where(eq(teamCompositions.guideId, guideId)), tx.delete(guideSections).where(eq(guideSections.guideId, guideId)), ]); const defaultSections = [ { kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 }, { kind: "artifact", slug: "artifact", title: "Artifacts & Stats", sortOrder: 1 }, { kind: "constellations", slug: "constellations", title: "Constellations", sortOrder: 2 }, { kind: "team", slug: "team", title: "Team Calc", sortOrder: 3 }, ]; await tx.insert(guideSections).values(defaultSections.map((section) => ({ ...section, guideId }))); if (draft.weapons.length) { await tx.insert(weaponRecommendations).values(draft.weapons.map((row, rowIndex) => ({ ...row, guideId, sortOrder: rowIndex, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, }))); } for (const [groupIndex, group] of draft.artifacts.groups.entries()) { const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder: groupIndex }).returning(); if (group.rows.length) { await tx.insert(artifactRecommendations).values(group.rows.map((row, rowIndex) => ({ ...row, groupId: created.id, sortOrder: rowIndex, percent: row.percent.toString(), }))); } } await tx.insert(artifactProfiles).values({ guideId, sands: draft.artifacts.sands, goblet: draft.artifacts.goblet, circlet: draft.artifacts.circlet, substats: draft.artifacts.substats, targets: draft.artifacts.targets, talents: draft.artifacts.talents, note: draft.artifacts.note, }); for (const [groupIndex, group] of draft.constellationGroups.entries()) { const [created] = await tx.insert(constellationConditions).values({ guideId, note: group.note, sortOrder: groupIndex }).returning(); if (group.rows.length) { await tx.insert(constellationRecommendations).values(group.rows.map((row) => ({ ...row, conditionId: created.id, cumulativePercent: row.cumulativePercent.toString(), }))); } } for (const [teamIndex, team] of draft.teams.entries()) { const [created] = await tx.insert(teamCompositions).values({ guideId, title: team.title, rotationSeconds: team.rotationSeconds?.toString() ?? null, combo: team.combo, sortOrder: teamIndex, }).returning(); await tx.insert(teamMembers).values(team.members.map((member, position) => ({ ...member, teamId: created.id, position, damage: member.damage.toString(), }))); } for (const [extraIndex, section] of draft.extras.entries()) { const [createdSection] = await tx.insert(guideSections).values({ guideId, kind: "extra", slug: `extra-${extraIndex + 1}`, title: section.title, sortOrder: extraIndex + 4, }).returning(); await tx.insert(extraSections).values({ sectionId: createdSection.id }); await tx.insert(extraSectionRows).values(section.rows.map((row, rowIndex) => ({ sectionId: createdSection.id, heading: row.heading, body: row.body, percent: row.percent?.toString() ?? null, ratio: "16:9", sortOrder: rowIndex, }))); } await tx.insert(outboxEvents).values([ { topic: "directory", aggregateId: guideId, eventType: "directory.updated", payload: { id: guideId, version } }, { topic: `page:${guideId}`, aggregateId: guideId, eventType: "page.updated", payload: { id: guideId, version } }, { topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version } }, ]); } async function applyDrafts(drafts: GuideImportDraft[]): Promise { const writable = drafts.filter((draft) => !draft.source.skipWrite); const covers = new Map(); for (const draft of writable) covers.set(draft.characterKey, await downloadCover(draft)); const storage = await getMediaStorage(); for (const cover of covers.values()) { await storage.write(cover.objectKey, cover.bytes, { type: cover.mimeType, acl: "public-read" }); } const db = getDb(); const [{ maximum }] = await db.select({ maximum: sql`coalesce(max(${guides.sortOrder}), 0)` }).from(guides).where(isNull(guides.trashedAt)); await db.transaction(async (tx) => { for (const [index, draft] of writable.entries()) { const cover = covers.get(draft.characterKey)!; let [coverRecord] = await tx.select().from(media).where(eq(media.objectKey, cover.objectKey)).limit(1); if (!coverRecord) { [coverRecord] = await tx.insert(media).values({ objectKey: cover.objectKey, fileName: `cover.${cover.extension}`, mimeType: cover.mimeType, byteSize: cover.bytes.length, status: "ready", }).returning(); } else { [coverRecord] = await tx.update(media).set({ mimeType: cover.mimeType, byteSize: cover.bytes.length, status: "ready", }).where(eq(media.id, coverRecord.id)).returning(); } await replaceGuide(tx, draft, coverRecord.id, Number(maximum) + index + 1); } }); } async function main(): Promise { const catalog = await loadCatalog(); const drafts: GuideImportDraft[] = []; for (const source of guideImportSources) { const draft = await buildGuideImportDraft(source, catalog); validateDraft(draft); drafts.push(draft); } console.log(JSON.stringify({ mode: apply ? "apply" : "dry-run", guides: drafts.map(reportFor) }, null, 2)); if (!apply) return; await applyDrafts(drafts); const sourceKeys = drafts.map((draft) => draft.characterKey); const active = await getDb().select({ characterKey: guides.characterKey }).from(guides).where(and(inArray(guides.characterKey, sourceKeys), isNull(guides.trashedAt))); if (active.length !== sourceKeys.length) throw new Error(`Expected ${sourceKeys.length} active source guides, found ${active.length}`); console.log(`Imported ${drafts.filter((draft) => !draft.source.skipWrite).length} guides; ${active.length} source guides are active.`); } try { await main(); } finally { await closeDb(); }