import { Router } from "express"; import { z } from "zod"; import { db } from "../db/client.js"; import { requireAuth, type AuthedRequest } from "../middleware.js"; const characterSchema = z.object({ campaignId: z.number().int(), characterName: 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().optional(), pronouns: z.string().optional(), portraitUrl: z.string().url().optional(), bio: z.string().optional(), notes: z.string().optional() }); export const characterRoutes = Router(); characterRoutes.post("/characters", requireAuth, (req: AuthedRequest, res) => { const parsed = characterSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; } const campaign = db .prepare("SELECT id FROM campaigns WHERE id = ? AND guild_id = ?") .get(parsed.data.campaignId, req.auth!.guildId); if (!campaign) { res.status(404).json({ error: "Campaign not found" }); return; } const result = db .prepare( `INSERT INTO characters (campaign_id, user_id, character_name, race, class, level, alignment, pronouns, portrait_url, bio, notes, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)` ) .run( parsed.data.campaignId, req.auth!.userId, parsed.data.characterName, parsed.data.race, parsed.data.class, parsed.data.level, parsed.data.alignment ?? null, parsed.data.pronouns ?? null, parsed.data.portraitUrl ?? null, parsed.data.bio ?? null, parsed.data.notes ?? null ); res.status(201).json({ id: Number(result.lastInsertRowid) }); }); characterRoutes.get("/characters/mine", requireAuth, (req: AuthedRequest, res) => { const characters = db.prepare( `SELECT c.id, c.character_name, c.class, c.race, c.level, c.alignment, c.portrait_url, c.bio, c.notes, c.stats_json, c.dndbeyond_id, c.dndbeyond_last_sync, c.is_active, camp.name AS campaign_name, camp.id AS campaign_id FROM characters c JOIN campaigns camp ON camp.id = c.campaign_id WHERE c.user_id = ? AND camp.guild_id = ? ORDER BY camp.name ASC, c.character_name ASC` ).all(req.auth!.userId, req.auth!.guildId); res.json(characters); }); characterRoutes.get("/characters/:id", requireAuth, (req: AuthedRequest, res) => { const id = Number(req.params.id); const char = db.prepare( `SELECT c.id, c.character_name, c.class, c.race, c.level, c.alignment, c.pronouns, c.portrait_url, c.bio, c.notes, c.stats_json, c.dndbeyond_id, c.dndbeyond_last_sync, c.is_active, c.user_id, camp.name AS campaign_name, camp.id AS campaign_id FROM characters c JOIN campaigns camp ON camp.id = c.campaign_id WHERE c.id = ? AND camp.guild_id = ?` ).get(id, req.auth!.guildId); if (!char) { res.status(404).json({ error: "Character not found" }); return; } res.json(char); }); const patchCharacterSchema = z.object({ characterName: z.string().min(1).max(100).optional(), race: z.string().min(1).max(100).optional(), class: z.string().min(1).max(100).optional(), level: z.number().int().min(1).max(20).optional(), alignment: z.string().max(50).nullable().optional(), pronouns: z.string().max(50).nullable().optional(), bio: z.string().nullable().optional(), notes: z.string().nullable().optional(), }); characterRoutes.patch("/characters/:id", requireAuth, (req: AuthedRequest, res) => { const id = Number(req.params.id); const parsed = patchCharacterSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.flatten() }); return; } const char = db.prepare("SELECT id, user_id FROM characters WHERE id = ?").get(id) as { id: number; user_id: number } | undefined; if (!char || char.user_id !== req.auth!.userId) { res.status(404).json({ error: "Character not found" }); return; } const d = parsed.data; const sets: string[] = []; const vals: unknown[] = []; if (d.characterName !== undefined) { sets.push("character_name = ?"); vals.push(d.characterName); } if (d.race !== undefined) { sets.push("race = ?"); vals.push(d.race); } if (d.class !== undefined) { sets.push("class = ?"); vals.push(d.class); } if (d.level !== undefined) { sets.push("level = ?"); vals.push(d.level); } if (d.alignment !== undefined) { sets.push("alignment = ?"); vals.push(d.alignment); } if (d.pronouns !== undefined) { sets.push("pronouns = ?"); vals.push(d.pronouns); } if (d.bio !== undefined) { sets.push("bio = ?"); vals.push(d.bio); } if (d.notes !== undefined) { sets.push("notes = ?"); vals.push(d.notes); } if (sets.length === 0) { res.json({ ok: true }); return; } vals.push(id); db.prepare(`UPDATE characters SET ${sets.join(", ")} WHERE id = ?`).run(...vals); res.json({ ok: true }); }); characterRoutes.post("/characters/:id/activate", requireAuth, (req: AuthedRequest, res) => { const id = Number(req.params.id); const character = db .prepare("SELECT id, campaign_id, user_id FROM characters WHERE id = ?") .get(id) as { id: number; campaign_id: number; user_id: number } | undefined; if (!character || character.user_id !== req.auth!.userId) { res.status(404).json({ error: "Character not found" }); return; } db.prepare("UPDATE characters SET is_active = 0 WHERE campaign_id = ? AND user_id = ?").run( character.campaign_id, req.auth!.userId ); db.prepare("UPDATE characters SET is_active = 1 WHERE id = ?").run(id); res.json({ ok: true }); });