import "server-only"; import { and, asc, desc, eq, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm"; import { getDb } from "@/db"; import { artifactGroups, artifactConditions, artifactProfiles, artifactRecommendations, catalogArtifacts, catalogCharacters, catalogMaterials, catalogWeapons, constellationConditions, constellationRecommendations, extraSectionRows, guides, guidePosterBleeding, guideSections, glossaryAliases, media, teamCompositions, teamConditions, teamMembers, weaponRecommendations, weaponConditions, } from "@/db/schema"; import { publicMediaUrl } from "@/lib/media/storage"; import { aliasesByItem } from "@/lib/glossary"; import type { PosterBlendMode } from "@/components/public/material-poster"; const posterBlendModes = new Set(["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"]); // Leave most of the per-pod pool available to other visitors during cache misses. const PUBLIC_QUERY_CONCURRENCY = 3; async function runWithConcurrency< const T extends ReadonlyArray<() => Promise>, >( tasks: T, concurrency = PUBLIC_QUERY_CONCURRENCY, ): Promise<{ [K in keyof T]: Awaited> }> { const results = new Array(tasks.length); let next = 0; await Promise.all( Array.from({ length: Math.min(concurrency, tasks.length) }, async () => { while (next < tasks.length) { const index = next++; results[index] = await tasks[index](); } }), ); return results as { [K in keyof T]: Awaited> }; } function catalogCharacterWithUrls( item: typeof catalogCharacters.$inferSelect, materialByKey = new Map(), ) { const materialWithMetadata = ( entry: typeof item.ascension[number]["items"][number], ) => { const material = materialByKey.get(entry.key); return { ...entry, name: material?.name ?? entry.key, description: material?.description ?? null, rarity: material?.rarity ?? null, type: material?.type ?? null, imageUrl: material ? publicMediaUrl(material.imageKey) : null, lunarisUrl: `https://lunaris.moe/material/${encodeURIComponent(entry.key)}`, available: Boolean(material), }; }; 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, })), ascension: item.ascension.map((group) => ({ ...group, items: group.items.map(materialWithMetadata), })), leveling: item.leveling.map((group) => ({ ...group, items: group.items.map(materialWithMetadata), })), }; } export async function getActiveCatalog() { const db = getDb(); const [characters, weapons, artifacts, materials, 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(catalogMaterials).orderBy(asc(catalogMaterials.name)), db.select().from(glossaryAliases), ]); if (!characters.length) return null; const groupedAliases = aliasesByItem(aliases); const materialByKey = new Map(materials.map((item) => [item.key, item])); return { characters: characters.map((item) => ({ ...catalogCharacterWithUrls(item, materialByKey), 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) })), materials: materials.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey), lunarisUrl: `https://lunaris.moe/material/${encodeURIComponent(item.key)}` })), }; } // Picker data deliberately excludes character descriptions, talents and materials. // Editors obtain the selected character's details from their guide content. export async function getCatalogOptions() { const [characters, weapons, artifacts, aliases] = await runWithConcurrency([ () => getDb().select({ key: catalogCharacters.key, name: catalogCharacters.name, rarity: catalogCharacters.rarity, weaponType: catalogCharacters.weaponType, element: catalogCharacters.element, imageKey: catalogCharacters.imageKey, elementIconKey: catalogCharacters.elementIconKey, }).from(catalogCharacters).orderBy(asc(catalogCharacters.name)), () => getDb().select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)), () => getDb().select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)), () => getDb().select().from(glossaryAliases), ]); if (!characters.length) return null; const groupedAliases = aliasesByItem(aliases); return { characters: characters.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey), elementIconUrl: item.elementIconKey ? publicMediaUrl(item.elementIconKey) : null, aliases: groupedAliases.character.get(item.key) ?? [], })), weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey), aliases: groupedAliases.weapon.get(item.key) ?? [] })), artifacts: artifacts.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey), aliases: groupedAliases.artifact.get(item.key) ?? [] })), }; } export async function listGuideCards(includeTrash = false) { const rows = await getDb() .select({ id: guides.id, characterKey: guides.characterKey, name: guides.name, overview: guides.overview, version: guides.version, updatedAt: guides.updatedAt, trashedAt: guides.trashedAt, isPublic: guides.isPublic, coverObjectKey: media.objectKey, }) .from(guides) .innerJoin(media, eq(media.id, guides.coverMediaId)) .where(includeTrash ? isNotNull(guides.trashedAt) : isNull(guides.trashedAt)) .orderBy(desc(guides.sortOrder), desc(guides.createdAt), asc(guides.name)); return rows.map(({ coverObjectKey, ...guide }) => ({ ...guide, coverUrl: publicMediaUrl(coverObjectKey), })); } export async function listAdminTeamImportGuides(excludeGuideId: string) { const rows = await getDb() .select({ id: guides.id, name: guides.name, characterKey: guides.characterKey, coverObjectKey: media.objectKey, }) .from(guides) .innerJoin(media, eq(media.id, guides.coverMediaId)) .where(and(isNull(guides.trashedAt), ne(guides.id, excludeGuideId))) .orderBy(desc(guides.sortOrder), desc(guides.createdAt), asc(guides.name)); return rows.map(({ coverObjectKey, ...guide }) => ({ ...guide, coverUrl: publicMediaUrl(coverObjectKey), })); } export async function listAdminTeamImportsForCharacter( excludeGuideId: string, characterKey: string, ) { const matches = await getDb() .select({ id: teamCompositions.id, title: teamCompositions.title, rotationSeconds: teamCompositions.rotationSeconds, combo: teamCompositions.combo, guideName: guides.name, conditionName: teamConditions.name, }) .from(teamCompositions) .innerJoin(guides, eq(guides.id, teamCompositions.guideId)) .innerJoin(teamMembers, eq(teamMembers.teamId, teamCompositions.id)) .leftJoin(teamConditions, eq(teamConditions.id, teamCompositions.conditionId)) .where(and( eq(teamMembers.characterKey, characterKey), ne(guides.id, excludeGuideId), isNull(guides.trashedAt), )) .orderBy(asc(guides.name), asc(guides.id), asc(teamConditions.sortOrder), asc(teamCompositions.sortOrder), asc(teamCompositions.id)); if (!matches.length) return []; const members = await getDb() .select({ member: teamMembers, characterName: catalogCharacters.name }) .from(teamMembers) .leftJoin(catalogCharacters, eq(catalogCharacters.key, teamMembers.characterKey)) .where(inArray(teamMembers.teamId, matches.map((team) => team.id))) .orderBy(asc(teamMembers.position)); const membersByTeam = new Map>(); for (const { member, characterName } of members) { const group = membersByTeam.get(member.teamId) ?? []; group.push({ characterKey: member.characterKey, characterName: characterName ?? member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage, }); membersByTeam.set(member.teamId, group); } return matches.map((team) => ({ ...team, members: membersByTeam.get(team.id) ?? [] })); } export async function getAdminTeamImportGuide(guideId: string) { const [guide] = await getDb() .select({ id: guides.id, name: guides.name }) .from(guides) .where(and(eq(guides.id, guideId), isNull(guides.trashedAt))) .limit(1); if (!guide) return null; const [conditions, teams] = await Promise.all([ getDb().select().from(teamConditions) .where(eq(teamConditions.guideId, guideId)) .orderBy(asc(teamConditions.sortOrder)), getDb().select().from(teamCompositions) .where(eq(teamCompositions.guideId, guideId)) .orderBy(asc(teamCompositions.sortOrder)), ]); const members = teams.length ? await getDb() .select({ member: teamMembers, characterName: catalogCharacters.name }) .from(teamMembers) .leftJoin(catalogCharacters, eq(catalogCharacters.key, teamMembers.characterKey)) .where(inArray(teamMembers.teamId, teams.map((team) => team.id))) .orderBy(asc(teamMembers.position)) : []; const hydratedTeams = teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, characterName }) => ({ characterKey: member.characterKey, characterName: characterName ?? member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage, })), })); return { ...guide, groups: conditions.length ? conditions.map((condition) => ({ id: condition.id, name: condition.name, teams: hydratedTeams.filter((team) => teams.find((candidate) => candidate.id === team.id)?.conditionId === condition.id), })) : [{ id: "team-condition-default", name: null, teams: hydratedTeams }], }; } export async function listPublicGuideCards() { const [rows, aliases] = await Promise.all([getDb() .select({ id: guides.id, characterKey: guides.characterKey, slug: guides.slug, name: guides.name, overview: guides.overview, version: guides.version, updatedAt: guides.updatedAt, publishedAt: guides.publishedAt, element: catalogCharacters.element, elementIconKey: catalogCharacters.elementIconKey, 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, catalogKey: glossaryAliases.catalogKey, alias: glossaryAliases.alias, }).from(glossaryAliases)]); const grouped = aliasesByItem(aliases); return rows.map(({ coverObjectKey, elementIconKey, ...guide }) => ({ ...guide, aliases: grouped.character.get(guide.characterKey) ?? [], coverUrl: publicMediaUrl(coverObjectKey), elementIconUrl: elementIconKey ? publicMediaUrl(elementIconKey) : null, })); } export async function getGuideEditorHeader(characterKey: string) { const [row] = await getDb() .select({ id: guides.id, characterKey: guides.characterKey, slug: guides.slug, name: guides.name, version: guides.version, coverObjectKey: media.objectKey, }) .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.id)) .orderBy(asc(guideSections.sortOrder)); const { coverObjectKey, ...guide } = row; return { ...guide, coverUrl: publicMediaUrl(coverObjectKey), sections }; } export async function getGuideByCharacterKey(characterKey: string) { 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.characterKey, characterKey), isNull(guides.trashedAt))) .limit(1); if (!row) return null; const materialKeys = [...new Set( [...row.character.ascension, ...row.character.leveling].flatMap((group) => group.items.map((item) => item.key)), )]; const [materials, poster, bleeding] = await Promise.all([ materialKeys.length ? getDb().select().from(catalogMaterials).where(inArray(catalogMaterials.key, materialKeys)) : [], row.guide.posterArtMediaId ? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1) : Promise.resolve([]), getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)), ]); const materialByKey = new Map(materials.map((item) => [item.key, item])); 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), 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), sections, }; } /** Navigation probes return presence flags, never the content of other sections. */ async function populatedSections(guideId: string) { const rows = await getDb().select({ section: guideSections, populated: sql`case ${guideSections.kind} when 'weapon' then exists (select 1 from ${weaponRecommendations} r join ${weaponConditions} c on c.id = r.condition_id where c.guide_id = ${guideId}) when 'artifact' then exists (select 1 from ${artifactRecommendations} r join ${artifactGroups} g on g.id = r.group_id join ${artifactConditions} c on c.id = g.condition_id where c.guide_id = ${guideId}) or exists (select 1 from ${artifactProfiles} p where p.guide_id = ${guideId} and ( p.sands <> '[]'::jsonb or p.goblet <> '[]'::jsonb or p.circlet <> '[]'::jsonb or p.substats <> '[]'::jsonb or p.talents <> '[]'::jsonb or p.targets <> '[]'::jsonb or exists (select 1 from jsonb_array_elements(p.recommended_conditions) c where c->'sands' <> '[]'::jsonb or c->'goblet' <> '[]'::jsonb or c->'circlet' <> '[]'::jsonb or c->'substats' <> '[]'::jsonb) or exists (select 1 from jsonb_array_elements(p.target_conditions) c where c->'rows' <> '[]'::jsonb))) when 'constellations' then exists (select 1 from ${constellationRecommendations} r join ${constellationConditions} c on c.id = r.condition_id where c.guide_id = ${guideId}) when 'team' then exists (select 1 from ${teamCompositions} t where t.guide_id = ${guideId}) when 'extra' then true else exists (select 1 from ${extraSectionRows} r where r.section_id = ${guideSections.id}) end`, }).from(guideSections).where(and( eq(guideSections.guideId, guideId), or(eq(guideSections.enabled, true), eq(guideSections.kind, "extra")), )) .orderBy(asc(guideSections.sortOrder)); return rows.filter((row) => row.populated).map((row) => row.section); } export async function getPublicGuideSummary(slug: string) { const [row] = await getDb().select({ id: guides.id, slug: guides.slug, name: guides.name, overview: guides.overview, characterKey: guides.characterKey, coverObjectKey: media.objectKey, }).from(guides).innerJoin(media, eq(media.id, guides.coverMediaId)) .where(and(eq(guides.slug, slug), eq(guides.isPublic, true), isNull(guides.trashedAt))).limit(1); if (!row) return null; const { coverObjectKey, ...summary } = row; return { ...summary, coverUrl: publicMediaUrl(coverObjectKey), sections: await populatedSections(row.id) }; } async function getOverviewBuildSummary(guideId: string, sections: { slug: string }[]) { const hasSection = (slug: string) => sections.some((section) => section.slug === slug); const [weapons, artifacts, profiles] = await runWithConcurrency([ () => hasSection("weapon") ? getDb().select({ name: catalogWeapons.name, rarity: catalogWeapons.rarity, refinement: weaponRecommendations.refinement, note: weaponRecommendations.note, conditionId: weaponConditions.id, conditionName: weaponConditions.name, conditionNote: weaponConditions.note, }).from(weaponRecommendations) .innerJoin(weaponConditions, eq(weaponConditions.id, weaponRecommendations.conditionId)) .innerJoin(catalogWeapons, eq(catalogWeapons.key, weaponRecommendations.weaponKey)) .where(eq(weaponConditions.guideId, guideId)) .orderBy(asc(weaponConditions.sortOrder), asc(weaponRecommendations.sortOrder)).limit(30) : Promise.resolve([]), () => hasSection("artifact") ? getDb().select({ name: catalogArtifacts.name, secondKey: artifactRecommendations.secondArtifactKey, note: artifactRecommendations.note, conditionName: artifactConditions.name, conditionNote: artifactConditions.note, }).from(artifactRecommendations) .innerJoin(artifactGroups, eq(artifactGroups.id, artifactRecommendations.groupId)) .innerJoin(artifactConditions, eq(artifactConditions.id, artifactGroups.conditionId)) .innerJoin(catalogArtifacts, eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey)) .where(eq(artifactConditions.guideId, guideId)) .orderBy(asc(artifactConditions.sortOrder), asc(artifactGroups.sortOrder), asc(artifactRecommendations.sortOrder)).limit(1) : Promise.resolve([]), () => hasSection("artifact") ? getDb().select({ sands: artifactProfiles.sands, goblet: artifactProfiles.goblet, circlet: artifactProfiles.circlet, substats: artifactProfiles.substats, recommendedConditions: artifactProfiles.recommendedConditions, targets: artifactProfiles.targets, targetConditions: artifactProfiles.targetConditions, talents: artifactProfiles.talents, }).from(artifactProfiles).where(eq(artifactProfiles.guideId, guideId)).limit(1) : Promise.resolve([]), ]); const firstWeapons = weapons.filter((weapon) => weapon.conditionId === weapons[0]?.conditionId); const artifact = artifacts[0]; const [second] = artifact?.secondKey ? await getDb().select({ name: catalogArtifacts.name }) .from(catalogArtifacts).where(eq(catalogArtifacts.key, artifact.secondKey)).limit(1) : []; return { weapon: firstWeapons[0] ?? null, fourStarWeapon: firstWeapons.find((weapon) => weapon.rarity === 4) ?? null, artifact: artifact ? { ...artifact, secondName: second?.name ?? null } : null, profile: profiles[0] ?? null, }; } async function getGuideContent(slug: string, publicOnly: boolean, selectedSection?: string) { 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 availableSections = publicOnly ? await populatedSections(row.guide.id) : undefined; if (publicOnly && selectedSection !== "overview" && !availableSections?.some((section) => section.slug === selectedSection)) return null; const wants = (section: string) => selectedSection === undefined || selectedSection === section; const materialKeys = [...new Set( [...row.character.ascension, ...row.character.leveling].flatMap((group) => group.items.map((item) => item.key)), )]; const [sections, weaponConditionsRows, artifactConditionRows, profile, constellationGroups, teamConditionRows, teams, aliases, materials, poster, bleeding] = await runWithConcurrency([ () => getDb().select().from(guideSections).where(and( eq(guideSections.guideId, row.guide.id), ...(publicOnly ? [or(eq(guideSections.enabled, true), eq(guideSections.kind, "extra"))] : [eq(guideSections.enabled, true)]), )).orderBy(asc(guideSections.sortOrder)), () => wants("weapon") ? getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)) : Promise.resolve([]), () => wants("artifact") ? getDb().select().from(artifactConditions).where(eq(artifactConditions.guideId, row.guide.id)).orderBy(asc(artifactConditions.sortOrder)) : Promise.resolve([]), () => wants("artifact") ? getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1) : Promise.resolve([]), () => wants("constellations") ? getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)) : Promise.resolve([]), () => wants("team") ? getDb().select().from(teamConditions).where(eq(teamConditions.guideId, row.guide.id)).orderBy(asc(teamConditions.sortOrder)) : Promise.resolve([]), () => wants("team") ? getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)) : Promise.resolve([]), () => getDb().select({ kind: glossaryAliases.kind, catalogKey: glossaryAliases.catalogKey, alias: glossaryAliases.alias, }).from(glossaryAliases), () => wants("overview") && materialKeys.length ? getDb().select().from(catalogMaterials).where(inArray(catalogMaterials.key, materialKeys)) : Promise.resolve([]), () => wants("overview") && row.guide.posterArtMediaId ? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1) : Promise.resolve([]), () => wants("overview") ? getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)) : Promise.resolve([]), ]); const iconIds = [...new Set([ ...weaponConditionsRows, ...artifactConditionRows, ...constellationGroups, ...teamConditionRows, ...(profile[0]?.recommendedConditions ?? []), ...(profile[0]?.targetConditions ?? []), ].map((condition) => condition.iconMediaId).filter((id): id is string => Boolean(id)))]; const iconAssetsPromise = iconIds.length ? getDb().select({ id: media.id, objectKey: media.objectKey }).from(media).where(and(inArray(media.id, iconIds), eq(media.status, "ready"))) : Promise.resolve([]); const groups = artifactConditionRows.length ? await getDb().select().from(artifactGroups).where(inArray(artifactGroups.conditionId, artifactConditionRows.map((condition) => condition.id))).orderBy(asc(artifactGroups.sortOrder)) : []; const iconAssets = await iconAssetsPromise; const iconUrls = new Map(iconAssets.map((asset) => [asset.id, publicMediaUrl(asset.objectKey)])); const iconUrl = (id: string | null | undefined) => id ? iconUrls.get(id) ?? null : null; const groupedAliases = aliasesByItem(aliases); const materialByKey = new Map(materials.map((item) => [item.key, item])); const elementIconUrls = Object.fromEntries( (wants("artifact") ? 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" && wants(section.slug)).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 }; const memberWeaponByKey = new Map(memberWeapons.map((item) => [item.key, item])); const memberArtifactByKey = new Map(memberArtifacts.map((item) => [item.key, item])); const secondArtifactByKey = new Map(secondArtifacts.map((item) => [item.key, item])); const membersByTeam = Map.groupBy(members, ({ member }) => member.teamId); const hydratedTeams = teams.map((team) => ({ ...team, members: (membersByTeam.get(team.id) ?? []).map(({ member, character }) => { const weapon = memberWeaponByKey.get(member.weaponKey ?? ""); const artifact = memberArtifactByKey.get(member.artifactKey ?? ""); const secondArtifact = memberArtifactByKey.get(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 }; }) })); return { ...row.guide, buildSummary: publicOnly && selectedSection === "overview" ? await getOverviewBuildSummary(row.guide.id, availableSections ?? []) : null, 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) ?? [] }, elementIconUrls, sections, availableSections, weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })), weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, iconUrl: iconUrl(condition.iconMediaId), 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: artifactConditionRows.map((condition) => ({ ...condition, iconUrl: iconUrl(condition.iconMediaId), tiers: groups.filter((group) => group.conditionId === condition.id).map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifactByKey.get(recommendation.secondArtifactKey ?? ""); return { ...recommendation, first: { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, aliases: groupedAliases.artifact.get(second.key) ?? [], imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })) })), artifactProfile: profile[0] ? { ...profile[0], recommendedConditions: (profile[0].recommendedConditions.length ? profile[0].recommendedConditions : [{ name: null, note: null, iconMediaId: null, sands: profile[0].sands, goblet: profile[0].goblet, circlet: profile[0].circlet, substats: profile[0].substats }]).map((condition) => ({ ...condition, iconMediaId: condition.iconMediaId ?? null, iconUrl: iconUrl(condition.iconMediaId) })), targetConditions: (profile[0].targetConditions.length ? profile[0].targetConditions : [{ name: null, note: null, iconMediaId: null, rows: profile[0].targets }]).map((condition) => ({ ...condition, iconMediaId: condition.iconMediaId ?? null, iconUrl: iconUrl(condition.iconMediaId) })) } : null, constellationGroups: constellationGroups.map((group) => ({ ...group, iconUrl: iconUrl(group.iconMediaId), 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, detailNodes: catalog?.descriptionNodes, iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null, }; }), })), teams: hydratedTeams, teamGroups: teamConditionRows.length ? teamConditionRows.map((condition) => ({ ...condition, iconUrl: iconUrl(condition.iconMediaId), teams: hydratedTeams.filter((team) => team.conditionId === condition.id) })) : [{ id: "team-condition-default", guideId: row.guide.id, name: null, note: null, iconMediaId: null, iconUrl: null, sortOrder: 0, teams: hydratedTeams }], extras: sections.filter((section) => section.kind === "extra" && wants(section.slug)).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, section = "overview") { return getGuideContent(slug, true, section); } export function getAdminGuideContent(slug: string, section?: string) { return getGuideContent(slug, false, section); } 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>>; export type PublicGuideSummary = NonNullable>>;