import "server-only"; import { sql } from "drizzle-orm"; import { getDb } from "@/db"; import { artifactConditions, artifactGroups, artifactRecommendations, catalogArtifacts, catalogCharacters, catalogWeapons, guides, guideSections, stygianRecommendationGroups, stygianRecommendationItems, stygianSchedules, teamCompositions, teamMembers, weaponConditions, weaponRecommendations, } from "@/db/schema"; import { publicMediaUrl } from "@/lib/media/storage"; import type { AnalyticsCategory, AnalyticsItem, AnalyticsKind, PublicAnalytics } from "./model"; type AggregateRow = { source: "guide" | "stygian"; category: AnalyticsCategory | "summary"; kind: AnalyticsKind | "guide" | "schedule" | "team"; key: string; count: number; name: string | null; imageKey: string | null; rarity: number | null; guideId: string; teamSignature: string | null; }; // Aggregate structured records before joining only the catalog labels and images. // EXISTS keeps multiple section records from multiplying usage counts. export async function getPublicAnalytics(): Promise { const rows = await getDb().execute(sql` with public_guides as ( select g.id, g.name from ${guides} g where g.is_public = true and g.trashed_at is null ), public_sections as ( select s.guide_id, s.kind from ${guideSections} s join public_guides g on g.id = s.guide_id where s.enabled = true ), guide_teams as ( select t.id, t.guide_id from ${teamCompositions} t where exists (select 1 from public_sections s where s.guide_id = t.guide_id and s.kind = 'team') ), stygian_groups as ( select g.id, g.kind, g.schedule_id from ${stygianRecommendationGroups} g join ${stygianSchedules} s on s.schedule_id = g.schedule_id ), stygian_items as ( select i.*, g.kind as group_kind, g.schedule_id from ${stygianRecommendationItems} i join stygian_groups g on g.id = i.group_id ), members as ( select 'guide' as source, 'guide:' || t.guide_id as guide_id, m.team_id, m.character_key, m.weapon_key, m.artifact_key, m.second_artifact_key, m.constellation, m.refinement from ${teamMembers} m join guide_teams t on t.id = m.team_id union all select 'stygian', 'stygian:' || i.schedule_id, i.group_id, i.catalog_key, i.weapon_key, i.artifact_key, null, i.constellation_level::text, i.refinement::text from stygian_items i where i.group_kind = 'team' and i.kind = 'character' ), member_builds as ( select team_id, jsonb_build_array(character_key, weapon_key, (select jsonb_agg(key order by key) from ( select distinct key from (values (artifact_key), (second_artifact_key)) a(key) where key is not null and key <> '' ) artifact_sets), constellation, refinement) as build from members ), fingerprints as ( select team_id, md5(jsonb_agg(build order by build::text)::text) as signature from member_builds group by team_id ), all_teams as ( select 'guide' as source, 'guide:' || t.guide_id as guide_id, t.id from guide_teams t union all select 'stygian', 'stygian:' || schedule_id, id from stygian_groups where kind = 'team' ), team_groups as ( select t.source, t.guide_id, coalesce(f.signature, md5('[]')) as signature, count(*)::int as count from all_teams t left join fingerprints f on f.team_id = t.id group by t.source, t.guide_id, coalesce(f.signature, md5('[]')) ), usage as ( select m.source, m.guide_id, f.signature, 'team' as category, v.kind, v.key from members m join fingerprints f on f.team_id = m.team_id cross join lateral (values ('character', m.character_key), ('weapon', m.weapon_key), ('artifact', m.artifact_key), ('artifact', case when m.second_artifact_key <> m.artifact_key then m.second_artifact_key end) ) v(kind, key) union all select 'guide', 'guide:' || c.guide_id, null, 'recommendation', 'weapon', r.weapon_key from ${weaponRecommendations} r join ${weaponConditions} c on c.id = r.condition_id where exists (select 1 from public_sections s where s.guide_id = c.guide_id and s.kind = 'weapon') union all select 'guide', 'guide:' || c.guide_id, null, 'recommendation', 'artifact', v.key from ${artifactRecommendations} r join ${artifactGroups} g on g.id = r.group_id join ${artifactConditions} c on c.id = g.condition_id cross join lateral (values (r.first_artifact_key), (case when r.second_artifact_key <> r.first_artifact_key then r.second_artifact_key end) ) v(key) where exists (select 1 from public_sections s where s.guide_id = c.guide_id and s.kind = 'artifact') union all select 'stygian', 'stygian:' || i.schedule_id, null, 'recommendation', v.kind, v.key from stygian_items i cross join lateral (values ('weapon', case when i.kind = 'weapon' then i.catalog_key when i.kind = 'character' then i.weapon_key end), ('artifact', case when i.kind = 'artifact' then i.catalog_key when i.kind = 'character' then i.artifact_key end) ) v(kind, key) where i.group_kind = 'unit' ), counts as ( select source, guide_id, signature, category, kind, key, count(*)::int as count from usage where key is not null and key <> '' group by source, guide_id, signature, category, kind, key ), catalog as ( select 'character' as kind, key, name, image_key, rarity from ${catalogCharacters} union all select 'weapon', key, name, image_key, rarity from ${catalogWeapons} union all select 'artifact', key, name, image_key, rarity from ${catalogArtifacts} ) select c.source, c.category, c.kind, c.key, c.count, i.name, i.image_key as "imageKey", c.guide_id as "guideId", c.signature as "teamSignature", i.rarity from counts c left join catalog i on i.kind = c.kind and i.key = c.key union all select 'guide', 'summary', 'guide', '', 1, name, null, 'guide:' || id, null, null from public_guides union all select 'stygian', 'summary', 'schedule', '', 1, challenge_name, null, 'stygian:' || schedule_id, null, null from ${stygianSchedules} union all select source, 'summary', 'team', '', count, null, null, guide_id, signature, null from team_groups `); const data: PublicAnalytics = { guides: [], teamGroups: [], guideCount: 0, stygianScheduleCount: 0, guideTeamCount: 0, stygianTeamCount: 0, team: { character: [], weapon: [], artifact: [] }, recommendation: { weapon: [], artifact: [] }, }; const items = new Map(); for (const row of rows) { if (row.category === "summary") { if (row.kind === "guide" || row.kind === "schedule") { data.guides.push({ id: row.guideId, source: row.source, name: row.name ?? row.guideId }); if (row.kind === "guide") data.guideCount += row.count; else data.stygianScheduleCount += row.count; } if (row.kind === "team") { data.teamGroups.push({ guideId: row.guideId, signature: row.teamSignature!, count: row.count }); if (row.source === "guide") data.guideTeamCount += row.count; else data.stygianTeamCount += row.count; } continue; } if (row.kind !== "character" && row.kind !== "weapon" && row.kind !== "artifact") continue; if (row.category === "recommendation" && row.kind === "character") continue; const id = `${row.category}:${row.kind}:${row.key}`; let item = items.get(id); if (!item) { item = { key: row.key, name: row.name ?? row.key, imageUrl: row.imageKey ? publicMediaUrl(row.imageKey) : null, rarity: row.rarity, guideCount: 0, stygianCount: 0, contributions: [], }; items.set(id, item); if (row.category === "team") data.team[row.kind].push(item); else if (row.kind !== "character") data.recommendation[row.kind].push(item); } if (row.source === "guide") item.guideCount += row.count; else item.stygianCount += row.count; item.contributions.push({ guideId: row.guideId, teamSignature: row.teamSignature, count: row.count }); } data.guides.sort((a, b) => a.source.localeCompare(b.source) || a.name.localeCompare(b.name, "th") || a.id.localeCompare(b.id)); return data; }