import { Router } from "express"; import { z } from "zod"; import { db } from "../db/client.js"; import { requireAuth, type AuthedRequest } from "../middleware.js"; import { permissionService } from "../services/permissionService.js"; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const ALIGNMENT_MAP: Record = { 1: "Lawful Good", 2: "Neutral Good", 3: "Chaotic Good", 4: "Lawful Neutral", 5: "True Neutral", 6: "Chaotic Neutral", 7: "Lawful Evil", 8: "Neutral Evil", 9: "Chaotic Evil", }; // ─── D&D Beyond fetch + parse ──────────────────────────────────────────────── async function fetchDndBeyondChar(charId: string): Promise> { const url = `https://character-service.dndbeyond.com/character/v5/character/${charId}`; const res = await fetch(url, { headers: { "User-Agent": "dnd-hub/1.0 (private campaign manager; not for scraping)" }, signal: AbortSignal.timeout(10_000), }); if (res.status === 404) { throw Object.assign(new Error("Character not found — make sure the character is set to Public on D&D Beyond."), { status: 404 }); } if (res.status === 401 || res.status === 403) { throw Object.assign(new Error("Character is private. Set it to Public on D&D Beyond, then try again."), { status: 422 }); } if (!res.ok) throw new Error(`D&D Beyond returned ${res.status}`); const json = await res.json() as { data?: Record }; if (!json.data) throw new Error("Unexpected response format from D&D Beyond"); return json.data; } interface ParsedChar { character_name: string; race: string; class: string; level: number; alignment: string | null; portrait_url: string | null; bio: string | null; stats_json: string | null; } interface CharStats { str: number; dex: number; con: number; int: number; wis: number; cha: number; maxHp: number | null; proficiencyBonus: number; } function parseCharStats(data: Record, level: number): string | null { try { const baseStats = (data.stats as Array<{id: number; value: number | null}> | undefined) ?? []; const bonusStats = (data.bonusStats as Array<{id: number; value: number | null}> | undefined) ?? []; const overrideStats = (data.overrideStats as Array<{id: number; value: number | null}> | undefined) ?? []; function getAbility(id: number): number { const ov = overrideStats.find(s => s.id === id)?.value; if (ov != null) return ov; const base = baseStats.find(s => s.id === id)?.value ?? 10; const bonus = bonusStats.find(s => s.id === id)?.value ?? 0; return base + bonus; } const hpInfo = data.hitPointInfo as { override?: number | null; maximumHitPoints?: number } | undefined; const maxHp = hpInfo?.override ?? hpInfo?.maximumHitPoints ?? null; const proficiencyBonus = Math.ceil(level / 4) + 1; const stats: CharStats = { str: getAbility(1), dex: getAbility(2), con: getAbility(3), int: getAbility(4), wis: getAbility(5), cha: getAbility(6), maxHp: typeof maxHp === 'number' ? maxHp : null, proficiencyBonus, }; return JSON.stringify(stats); } catch { return null; } } function parseDndBeyondChar(data: Record): ParsedChar { const get = (obj: unknown, ...keys: string[]): T | undefined => { let cur: unknown = obj; for (const k of keys) { if (cur == null || typeof cur !== "object") return undefined; cur = (cur as Record)[k]; } return cur as T; }; const name = (data.name as string | undefined) ?? "Unknown"; const raceObj = data.race as Record | null | undefined; const race = (raceObj?.fullName ?? raceObj?.baseRaceName ?? "Unknown") as string; const classes = (data.classes as Array> | null | undefined) ?? []; const classNames = classes.map(c => get(c, "definition", "name") ?? "Unknown"); const classStr = classNames.length > 0 ? classNames.join("/") : "Unknown"; const level = Math.max(1, classes.reduce((sum, c) => sum + ((c.level as number) ?? 0), 0)); const alignmentId = data.alignmentId as number | null | undefined; const alignment = alignmentId ? (ALIGNMENT_MAP[alignmentId] ?? null) : null; const portrait_url = (get(data, "decorations", "avatarUrl") ?? null) as string | null; const traits = data.traits as Record | null | undefined; const traitParts = [ traits?.personalityTraits, traits?.ideals ? `**Ideals:** ${traits.ideals}` : null, traits?.bonds ? `**Bonds:** ${traits.bonds}` : null, traits?.flaws ? `**Flaws:** ${traits.flaws}` : null, ].filter(Boolean); const bio = traitParts.length > 0 ? traitParts.join("\n\n") : null; const stats_json = parseCharStats(data, level); return { character_name: name, race, class: classStr, level, alignment, portrait_url, bio, stats_json }; } // ─── Helper ────────────────────────────────────────────────────────────────── function getCharacter(characterId: number) { return db.prepare( "SELECT id, user_id, campaign_id, dndbeyond_id, dndbeyond_last_sync FROM characters WHERE id = ?" ).get(characterId) as { id: number; user_id: number; campaign_id: number; dndbeyond_id: string | null; dndbeyond_last_sync: string | null; } | undefined; } function applyRefresh(characterId: number, fields: ParsedChar) { db.prepare( `UPDATE characters SET character_name = ?, race = ?, class = ?, level = ?, alignment = ?, portrait_url = ?, bio = ?, stats_json = ?, dndbeyond_last_sync = datetime('now') WHERE id = ?` ).run( fields.character_name, fields.race, fields.class, fields.level, fields.alignment, fields.portrait_url, fields.bio, fields.stats_json, characterId, ); } // ─── Routes ────────────────────────────────────────────────────────────────── export const dndBeyondRoutes = Router(); /** * POST /characters/dndbeyond/preview * Fetch & parse a D&D Beyond character without saving anything. */ dndBeyondRoutes.post("/characters/dndbeyond/preview", requireAuth, async (req: AuthedRequest, res) => { const parsed = z.object({ dndbeyondCharId: z.string().trim().regex(/^\d+$/, "Must be a numeric D&D Beyond character ID"), }).safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; } try { const data = await fetchDndBeyondChar(parsed.data.dndbeyondCharId); res.json(parseDndBeyondChar(data)); } catch (err: unknown) { const e = err as { message?: string; status?: number }; res.status(e.status ?? 502).json({ error: e.message ?? "Failed to fetch character" }); } }); /** * POST /characters/dndbeyond/import * After the user confirms the preview, persist to characters table. */ dndBeyondRoutes.post("/characters/dndbeyond/import", requireAuth, (req: AuthedRequest, res) => { const schema = z.object({ campaignId: z.number().int(), dndbeyondCharId: z.string().trim().regex(/^\d+$/), character_name: z.string().min(1), race: z.string().min(1), class: z.string().min(1), level: z.number().int().min(1).max(20), alignment: z.string().nullable().optional(), portrait_url: z.string().url().nullable().optional(), bio: z.string().nullable().optional(), }); const parsed = schema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; } const { campaignId, dndbeyondCharId, ...fields } = parsed.data; const campaign = db .prepare("SELECT id FROM campaigns WHERE id = ? AND guild_id = ?") .get(campaignId, req.auth!.guildId); if (!campaign) { res.status(404).json({ error: "Campaign not found" }); return; } // Prevent duplicate imports of the same DnD Beyond character for this campaign const duplicate = db .prepare("SELECT id FROM characters WHERE campaign_id = ? AND dndbeyond_id = ?") .get(campaignId, dndbeyondCharId); if (duplicate) { res.status(409).json({ error: "This D&D Beyond character is already imported for this campaign" }); return; } const result = db.prepare( `INSERT INTO characters (campaign_id, user_id, character_name, race, class, level, alignment, portrait_url, bio, is_active, dndbeyond_id, dndbeyond_last_sync, stats_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, datetime('now'), ?)` ).run( campaignId, req.auth!.userId, fields.character_name, fields.race, fields.class, fields.level, fields.alignment ?? null, fields.portrait_url ?? null, fields.bio ?? null, dndbeyondCharId, null, ); res.status(201).json({ id: Number(result.lastInsertRowid) }); }); /** * POST /characters/:characterId/dndbeyond/refresh * Re-fetch from D&D Beyond. Rate-limited to once per 7 days per character. * Only the character owner can call this. */ dndBeyondRoutes.post("/characters/:characterId/dndbeyond/refresh", requireAuth, async (req: AuthedRequest, res) => { const characterId = Number(req.params.characterId); const char = getCharacter(characterId); if (!char || char.user_id !== req.auth!.userId) { res.status(404).json({ error: "Character not found" }); return; } if (!char.dndbeyond_id) { res.status(400).json({ error: "Character has no D&D Beyond link" }); return; } // Rate-limit if (char.dndbeyond_last_sync) { const msSince = Date.now() - new Date(char.dndbeyond_last_sync).getTime(); if (msSince < SEVEN_DAYS_MS) { const daysLeft = Math.ceil((SEVEN_DAYS_MS - msSince) / (24 * 60 * 60 * 1000)); res.status(429).json({ error: `Refresh available in ${daysLeft} day${daysLeft === 1 ? "" : "s"}`, daysLeft, }); return; } } try { const data = await fetchDndBeyondChar(char.dndbeyond_id); const fields = parseDndBeyondChar(data); applyRefresh(characterId, fields); res.json({ ok: true, character: fields }); } catch (err: unknown) { const e = err as { message?: string; status?: number }; res.status(e.status ?? 502).json({ error: e.message ?? "Failed to fetch character" }); } }); /** * POST /characters/:characterId/dndbeyond/admin-refresh * Same as refresh but bypasses the 7-day rate limit. * Requires dm or admin role and the character must be in this guild. */ dndBeyondRoutes.post("/characters/:characterId/dndbeyond/admin-refresh", requireAuth, async (req: AuthedRequest, res) => { if (!permissionService.hasRole(req.auth!.guildId, req.auth!.userId, ["dm", "admin"])) { res.status(403).json({ error: "Forbidden" }); return; } const characterId = Number(req.params.characterId); const char = getCharacter(characterId); if (!char) { res.status(404).json({ error: "Character not found" }); return; } if (!char.dndbeyond_id) { res.status(400).json({ error: "Character has no D&D Beyond link" }); return; } // Verify the character belongs to a campaign in this guild const inGuild = db .prepare("SELECT id FROM campaigns WHERE id = ? AND guild_id = ?") .get(char.campaign_id, req.auth!.guildId); if (!inGuild) { res.status(404).json({ error: "Character not found" }); return; } try { const data = await fetchDndBeyondChar(char.dndbeyond_id); const fields = parseDndBeyondChar(data); applyRefresh(characterId, fields); res.json({ ok: true, character: fields }); } catch (err: unknown) { const e = err as { message?: string; status?: number }; res.status(e.status ?? 502).json({ error: e.message ?? "Failed to fetch character" }); } });