313 lines
12 KiB
TypeScript
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" });
|
|
}
|
|
});
|