dnd-hub/apps/server/src/routes/dndBeyondRoutes.ts
2026-03-16 22:15:15 -04:00

313 lines
12 KiB
TypeScript

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<number, string> = {
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<Record<string, unknown>> {
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<string, unknown> };
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<string, unknown>, 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<string, unknown>): ParsedChar {
const get = <T>(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<string, unknown>)[k];
}
return cur as T;
};
const name = (data.name as string | undefined) ?? "Unknown";
const raceObj = data.race as Record<string, unknown> | null | undefined;
const race = (raceObj?.fullName ?? raceObj?.baseRaceName ?? "Unknown") as string;
const classes = (data.classes as Array<Record<string, unknown>> | null | undefined) ?? [];
const classNames = classes.map(c => get<string>(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<string>(data, "decorations", "avatarUrl") ?? null) as string | null;
const traits = data.traits as Record<string, string | null> | 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" });
}
});