import "server-only"; import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import * as z from "zod"; import { getDb } from "@/db"; import { artifactGroups, artifactProfiles, artifactRecommendations, catalogArtifacts, catalogCharacters, catalogWeapons, constellationConditions, constellationRecommendations, extraSectionRows, guides, guideSections, glossaryAliases, media, teamCompositions, teamMembers, weaponRecommendations, weaponConditions, } from "@/db/schema"; import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage"; import { aliasesByItem } from "@/lib/glossary"; const catalogVersionSchema = z.object({ version: z.string() }); function catalogCharacterWithUrls(item: typeof catalogCharacters.$inferSelect) { return { ...item, imageUrl: publicMediaUrl(item.imageKey), splashImageUrl: item.coopImageKey ? publicMediaUrl(item.coopImageKey) : null, nameCardImageUrl: item.nameCardImageKey ? publicMediaUrl(item.nameCardImageKey) : null, elementIconUrl: item.elementIconKey ? publicMediaUrl(item.elementIconKey) : null, constellations: item.constellations.map((constellation) => ({ ...constellation, iconUrl: constellation.iconKey ? publicMediaUrl(constellation.iconKey) : null, })), talents: item.talents.map((talent) => ({ ...talent, iconUrl: talent.iconKey ? publicMediaUrl(talent.iconKey) : null, })), }; } export async function getActiveCatalog() { const db = getDb(); const [characters, weapons, artifacts, aliases] = await Promise.all([ db.select().from(catalogCharacters).orderBy(asc(catalogCharacters.name)), db.select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)), db.select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)), db.select().from(glossaryAliases), ]); if (!characters.length) return null; const groupedAliases = aliasesByItem(aliases); const storage = await getMediaStorage(); const { version } = catalogVersionSchema.parse( await storage.file("catalog/current.json").json(), ); return { version, characters: characters.map((item) => ({ ...catalogCharacterWithUrls(item), aliases: groupedAliases.character.get(item.key) ?? [] })), weapons: weapons.map((item) => ({ ...item, aliases: groupedAliases.weapon.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })), artifacts: artifacts.map((item) => ({ ...item, aliases: groupedAliases.artifact.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })), }; } export async function listGuideCards(includeTrash = false) { const rows = await getDb() .select({ guide: guides, cover: media }) .from(guides) .innerJoin(media, eq(media.id, guides.coverMediaId)) .where(includeTrash ? isNotNull(guides.trashedAt) : isNull(guides.trashedAt)) .orderBy(asc(guides.sortOrder), asc(guides.name)); return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) })); } export async function listPublicGuideCards() { const [rows, aliases] = await Promise.all([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)), getDb().select().from(glossaryAliases)]); const grouped = aliasesByItem(aliases); return rows.map(({ guide, cover }) => ({ ...guide, aliases: grouped.character.get(guide.characterKey) ?? [], coverUrl: publicMediaUrl(cover.objectKey) })); } export async function getGuideByCharacterKey(characterKey: string) { const [row] = await getDb() .select({ guide: guides, cover: media }) .from(guides) .innerJoin(media, eq(media.id, guides.coverMediaId)) .where(and(eq(guides.characterKey, characterKey), isNull(guides.trashedAt))) .limit(1); if (!row) return null; const sections = await getDb().select().from(guideSections).where(eq(guideSections.guideId, row.guide.id)).orderBy(asc(guideSections.sortOrder)); return { ...row.guide, coverUrl: publicMediaUrl(row.cover.objectKey), sections }; } 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), ...(publicOnly ? [eq(guides.isPublic, true)] : []), )) .limit(1); if (!row) return null; const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases] = await Promise.all([ getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)), getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)), getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)), getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1), getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)), getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)), getDb().select().from(glossaryAliases), ]); const groupedAliases = aliasesByItem(aliases); const elementIconUrls = Object.fromEntries( (await getDb() .select({ element: catalogCharacters.element, elementIconKey: catalogCharacters.elementIconKey, }) .from(catalogCharacters) .where(isNotNull(catalogCharacters.elementIconKey))) .flatMap((character) => character.element && character.elementIconKey ? [[character.element, publicMediaUrl(character.elementIconKey)] as const] : [], ), ); const weaponRows = weaponConditionsRows.length ? await getDb().select({ recommendation: weaponRecommendations, weapon: catalogWeapons }).from(weaponRecommendations).innerJoin(catalogWeapons, eq(catalogWeapons.key, weaponRecommendations.weaponKey)).where(inArray(weaponRecommendations.conditionId, weaponConditionsRows.map((condition) => condition.id))).orderBy(asc(weaponRecommendations.sortOrder)) : []; const constellations = constellationGroups.length ? await getDb().select().from(constellationRecommendations).where(inArray(constellationRecommendations.conditionId, constellationGroups.map((group) => group.id))).orderBy(asc(constellationRecommendations.level)) : []; const artifactRows = groups.length ? await getDb().select({ recommendation: artifactRecommendations, artifact: catalogArtifacts }).from(artifactRecommendations).innerJoin(catalogArtifacts, eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey)).where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id))).orderBy(asc(artifactRecommendations.sortOrder)) : []; const secondKeys = artifactRows.map(({ recommendation }) => recommendation.secondArtifactKey).filter((key): key is string => Boolean(key)); const secondArtifacts = secondKeys.length ? await getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, secondKeys)) : []; const members = teams.length ? await getDb().select({ member: teamMembers, character: catalogCharacters }).from(teamMembers).innerJoin(catalogCharacters, eq(catalogCharacters.key, teamMembers.characterKey)).where(inArray(teamMembers.teamId, teams.map((team) => team.id))).orderBy(asc(teamMembers.position)) : []; const memberWeaponKeys = members.map(({ member }) => member.weaponKey).filter((key): key is string => Boolean(key)); const memberArtifactKeys = members.flatMap(({ member }) => [member.artifactKey, member.secondArtifactKey]).filter((key): key is string => Boolean(key)); const [memberWeapons, memberArtifacts] = await Promise.all([ memberWeaponKeys.length ? getDb().select().from(catalogWeapons).where(inArray(catalogWeapons.key, memberWeaponKeys)) : [], memberArtifactKeys.length ? getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, memberArtifactKeys)) : [], ]); const extraIds = sections.filter((section) => section.kind === "extra").map((section) => section.id); const extraRows = extraIds.length ? await getDb().select({ row: extraSectionRows, asset: media }).from(extraSectionRows).leftJoin(media, eq(media.id, extraSectionRows.mediaId)).where(inArray(extraSectionRows.sectionId, extraIds)).orderBy(asc(extraSectionRows.sortOrder)) : []; const extraCharacterKeys = [...new Set(extraRows.flatMap(({ row: extraRow }) => extraRow.catalogItems.filter((item) => item.kind === "character").map((item) => item.key)))]; const extraWeaponKeys = [...new Set(extraRows.flatMap(({ row: extraRow }) => extraRow.catalogItems.filter((item) => item.kind === "weapon").map((item) => item.key)))]; const extraArtifactKeys = [...new Set(extraRows.flatMap(({ row: extraRow }) => extraRow.catalogItems.filter((item) => item.kind === "artifact").map((item) => item.key)))]; const [extraCharacters, extraWeapons, extraArtifacts] = await Promise.all([ extraCharacterKeys.length ? getDb().select().from(catalogCharacters).where(inArray(catalogCharacters.key, extraCharacterKeys)) : [], extraWeaponKeys.length ? getDb().select().from(catalogWeapons).where(inArray(catalogWeapons.key, extraWeaponKeys)) : [], extraArtifactKeys.length ? getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, extraArtifactKeys)) : [], ]); const extraCatalogs = { character: extraCharacters, weapon: extraWeapons, artifact: extraArtifacts }; return { ...row.guide, coverUrl: publicMediaUrl(row.cover.objectKey), character: { ...catalogCharacterWithUrls(row.character), aliases: groupedAliases.character.get(row.character.key) ?? [] }, elementIconUrls, sections, weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })), weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })) })), artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })), artifactProfile: profile[0] ?? null, constellationGroups: constellationGroups.map((group) => ({ ...group, rows: constellations.filter((recommendation) => recommendation.conditionId === group.id).map((recommendation) => { const catalog = row.character.constellations.find((item) => item.level === recommendation.level); return { ...recommendation, name: catalog?.name ?? `C${recommendation.level}`, detail: catalog?.description ?? recommendation.detail, iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null, }; }), })), teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: { ...catalogCharacterWithUrls(character), aliases: groupedAliases.character.get(character.key) ?? [] }, weapon: weapon ? { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, aliases: groupedAliases.artifact.get(secondArtifact.key) ?? [], imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })), extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({ ...extraRow, catalogItems: extraRow.catalogItems.map((reference) => { const item = extraCatalogs[reference.kind].find((candidate) => candidate.key === reference.key); return { ...reference, name: item?.name ?? reference.key, imageUrl: item ? publicMediaUrl(item.imageKey) : null, rarity: item?.rarity ?? null, available: Boolean(item), }; }), imageUrl: asset ? publicMediaUrl(asset.objectKey) : null, imageWidth: asset?.width ?? null, imageHeight: asset?.height ?? null, })) })), }; } export function getPublicGuide(slug: string) { return getGuideContent(slug, true); } export function getAdminGuideContent(slug: string) { return getGuideContent(slug, false); } export type PublicGuide = NonNullable>>;