Files
buzz-sheet/lib/guides/queries.ts
T
gunshiz dd87510d68
CI / Verify (push) Successful in 1m5s
CI / Build immutable images and deploy (push) Successful in 1m55s
feat(guides) : add team transfer and visibility controls
2026-09-04 19:46:46 +07:00

188 lines
11 KiB
TypeScript

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,
media,
teamCompositions,
teamMembers,
weaponRecommendations,
weaponConditions,
} from "@/db/schema";
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
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] = 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)),
]);
if (!characters.length) return null;
const storage = await getMediaStorage();
const { version } = catalogVersionSchema.parse(
await storage.file("catalog/current.json").json(),
);
return {
version,
characters: characters.map(catalogCharacterWithUrls),
weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
artifacts: artifacts.map((item) => ({ ...item, 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 = await 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));
return rows.map(({ guide, cover }) => ({ ...guide, 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] = 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)),
]);
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)) : [];
return {
...row.guide,
coverUrl: publicMediaUrl(row.cover.objectKey),
character: catalogCharacterWithUrls(row.character),
elementIconUrls,
sections,
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })),
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, 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), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, 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, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })) })),
};
}
export function getPublicGuide(slug: string) {
return getGuideContent(slug, true);
}
export function getAdminGuideContent(slug: string) {
return getGuideContent(slug, false);
}
export type PublicGuide = NonNullable<Awaited<ReturnType<typeof getPublicGuide>>>;