From 385d9b6e9e5b5996acd064797400cda8034f4c2d Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Sat, 11 Apr 2026 00:14:49 -0400 Subject: [PATCH] feat: convert characters routes to async mysql2 Replace synchronous better-sqlite3 calls with async mysql2 db.execute(), import parseJson from shared utility, and add try/catch error handling throughout all character, gear, talent, and stat endpoints. Co-Authored-By: Claude Sonnet 4.6 --- server/src/routes/characters.ts | 676 ++++++++++++++++---------------- 1 file changed, 343 insertions(+), 333 deletions(-) diff --git a/server/src/routes/characters.ts b/server/src/routes/characters.ts index dc58e3d..13ab179 100644 --- a/server/src/routes/characters.ts +++ b/server/src/routes/characters.ts @@ -1,8 +1,10 @@ import { Router } from "express"; import type { ParamsDictionary } from "express-serve-static-core"; +import type { RowDataPacket, ResultSetHeader } from "mysql2"; import type { Server } from "socket.io"; import db from "../db.js"; import { broadcastToCampaign } from "../socket.js"; +import { parseJson } from "../utils/parseJson.js"; type CampaignParams = ParamsDictionary & { campaignId: string }; @@ -15,18 +17,7 @@ function generateCharacterColor(): string { return `hsl(${hue}, 60%, 65%)`; } -function parseJson(val: unknown): Record { - if (typeof val === "string") { - try { - return JSON.parse(val); - } catch { - return {}; - } - } - return (val as Record) ?? {}; -} - -function parseGear(rows: Array>) { +function parseGear(rows: RowDataPacket[]) { return rows.map((r) => ({ ...r, properties: parseJson(r.properties), @@ -34,360 +25,379 @@ function parseGear(rows: Array>) { })); } -function parseTalents(rows: Array>) { +function parseTalents(rows: RowDataPacket[]) { return rows.map((r) => ({ ...r, effect: parseJson(r.effect) })); } -// GET /api/campaigns/:campaignId/characters — list characters in a campaign -router.get("/", (req, res) => { - const { campaignId } = req.params; - const characters = db - .prepare("SELECT * FROM characters WHERE campaign_id = ? ORDER BY name") - .all(campaignId) as Array>; - - const stmtStats = db.prepare( - "SELECT stat_name, value FROM character_stats WHERE character_id = ?", - ); - const stmtGear = db.prepare( - "SELECT * FROM character_gear WHERE character_id = ?", - ); - const stmtTalents = db.prepare( - "SELECT * FROM character_talents WHERE character_id = ?", - ); - - const enriched = characters.map((char) => ({ - ...char, - overrides: parseJson(char.overrides), - stats: stmtStats.all(char.id), - gear: parseGear(stmtGear.all(char.id) as Array>), - talents: parseTalents( - stmtTalents.all(char.id) as Array>, - ), - })); - - res.json(enriched); -}); - -// POST /api/campaigns/:campaignId/characters — create a character -router.post("/", (req, res) => { - const { campaignId } = req.params; - const { name, class: charClass, ancestry, hp_max } = req.body; - - if (!name || !name.trim()) { - res.status(400).json({ error: "Character name is required" }); - return; - } - - const insertChar = db.prepare(` - INSERT INTO characters (campaign_id, name, class, ancestry, hp_current, hp_max, color) - VALUES (?, ?, ?, ?, ?, ?, ?) - `); - - const insertStat = db.prepare( - "INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, 10)", - ); - - const result = insertChar.run( - campaignId, - name.trim(), - charClass || "Fighter", - ancestry || "Human", - hp_max || 0, - hp_max || 0, - generateCharacterColor(), - ); - const characterId = result.lastInsertRowid; - - for (const stat of DEFAULT_STATS) { - insertStat.run(characterId, stat); - } - - const character = db - .prepare("SELECT * FROM characters WHERE id = ?") - .get(characterId); - const stats = db - .prepare( - "SELECT stat_name, value FROM character_stats WHERE character_id = ?", - ) - .all(characterId); - - const enriched = { - ...(character as Record), - overrides: {}, - stats, - gear: [], - talents: [], - }; - - const io: Server = req.app.get("io"); - broadcastToCampaign(io, Number(campaignId), "character:created", enriched); - - res.status(201).json(enriched); -}); - -// PATCH /api/characters/:id — update character fields -router.patch("/:id", (req, res) => { - const { id } = req.params; - const allowedFields = [ - "name", - "class", - "ancestry", - "level", - "xp", - "hp_current", - "hp_max", - "ac", - "alignment", - "title", - "notes", - "background", - "deity", - "languages", - "gp", - "sp", - "cp", - "gear_slots_max", - "overrides", - "color", - "luck_token", - "torch_lit_at", - ]; - - const updates: string[] = []; - const values: unknown[] = []; - - for (const field of allowedFields) { - if (req.body[field] !== undefined) { - updates.push(`${field} = ?`); - const val = req.body[field]; - // JSON-stringify object fields for SQLite TEXT storage - values.push( - typeof val === "object" && val !== null ? JSON.stringify(val) : val, +async function enrichCharacters(characters: RowDataPacket[]) { + return Promise.all( + characters.map(async (char) => { + const [stats] = await db.execute( + "SELECT stat_name, value FROM character_stats WHERE character_id = ?", + [char.id] ); - } - } - - if (updates.length === 0) { - res.status(400).json({ error: "No valid fields to update" }); - return; - } - - values.push(id); - db.prepare(`UPDATE characters SET ${updates.join(", ")} WHERE id = ?`).run( - ...values, + const [gear] = await db.execute( + "SELECT * FROM character_gear WHERE character_id = ?", + [char.id] + ); + const [talents] = await db.execute( + "SELECT * FROM character_talents WHERE character_id = ?", + [char.id] + ); + return { + ...char, + overrides: parseJson(char.overrides), + stats, + gear: parseGear(gear), + talents: parseTalents(talents), + }; + }) ); +} - const character = db - .prepare("SELECT * FROM characters WHERE id = ?") - .get(id) as Record; - if (!character) { - res.status(404).json({ error: "Character not found" }); - return; +// GET /api/campaigns/:campaignId/characters +router.get("/", async (req, res) => { + try { + const { campaignId } = req.params; + const [characters] = await db.execute( + "SELECT * FROM characters WHERE campaign_id = ? ORDER BY name", + [campaignId] + ); + const enriched = await enrichCharacters(characters); + res.json(enriched); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); } - - const io: Server = req.app.get("io"); - broadcastToCampaign(io, Number(character.campaign_id), "character:updated", { - id: Number(id), - ...req.body, - }); - - res.json(character); }); -// DELETE /api/characters/:id — delete a character -router.delete("/:id", (req, res) => { - const character = db - .prepare("SELECT * FROM characters WHERE id = ?") - .get(req.params.id) as Record | undefined; - if (!character) { - res.status(404).json({ error: "Character not found" }); - return; - } +// POST /api/campaigns/:campaignId/characters +router.post("/", async (req, res) => { + try { + const { campaignId } = req.params; + const { name, class: charClass, ancestry, hp_max } = req.body; - db.prepare("DELETE FROM characters WHERE id = ?").run(req.params.id); + if (!name?.trim()) { + res.status(400).json({ error: "Character name is required" }); + return; + } - const io: Server = req.app.get("io"); - broadcastToCampaign(io, Number(character.campaign_id), "character:deleted", { - id: Number(req.params.id), - }); + const [result] = await db.execute( + `INSERT INTO characters + (campaign_id, name, class, ancestry, hp_current, hp_max, color) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + campaignId, + name.trim(), + charClass ?? "Fighter", + ancestry ?? "Human", + hp_max ?? 0, + hp_max ?? 0, + generateCharacterColor(), + ] + ); + const characterId = result.insertId; - res.status(204).end(); -}); - -// PATCH /api/characters/:id/stats/:statName — update a single stat -router.patch("/:id/stats/:statName", (req, res) => { - const { id, statName } = req.params; - const { value } = req.body; - const upper = statName.toUpperCase(); - - if (!DEFAULT_STATS.includes(upper)) { - res.status(400).json({ error: "Invalid stat name" }); - return; - } - - db.prepare( - "UPDATE character_stats SET value = ? WHERE character_id = ? AND stat_name = ?", - ).run(value, id, upper); - - const character = db - .prepare("SELECT campaign_id FROM characters WHERE id = ?") - .get(id) as Record; - if (!character) { - res.status(404).json({ error: "Character not found" }); - return; - } - - const io: Server = req.app.get("io"); - broadcastToCampaign(io, Number(character.campaign_id), "stat:updated", { - characterId: Number(id), - statName: upper, - value, - }); - - res.json({ characterId: Number(id), statName: upper, value }); -}); - -// POST /api/characters/:id/gear — add gear -router.post("/:id/gear", (req, res) => { - const { id } = req.params; - const { name, type, slot_count, properties, effects, game_item_id } = - req.body; - - if (!name || !name.trim()) { - res.status(400).json({ error: "Gear name is required" }); - return; - } - - const result = db - .prepare( - "INSERT INTO character_gear (character_id, name, type, slot_count, properties, effects, game_item_id) VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .run( - id, - name.trim(), - type || "gear", - slot_count ?? 1, - JSON.stringify(properties || {}), - JSON.stringify(effects || {}), - game_item_id ?? null, + await Promise.all( + DEFAULT_STATS.map((stat) => + db.execute( + "INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, 10)", + [characterId, stat] + ) + ) ); - const gearRow = db - .prepare("SELECT * FROM character_gear WHERE id = ?") - .get(result.lastInsertRowid) as Record; - const gear = { - ...gearRow, - properties: parseJson(gearRow.properties), - effects: parseJson(gearRow.effects), - }; + const [charRows] = await db.execute( + "SELECT * FROM characters WHERE id = ?", + [characterId] + ); + const enriched = { + ...charRows[0], + overrides: {}, + stats: DEFAULT_STATS.map((s) => ({ stat_name: s, value: 10 })), + gear: [], + talents: [], + }; - const character = db - .prepare("SELECT campaign_id FROM characters WHERE id = ?") - .get(id) as Record; - - const io: Server = req.app.get("io"); - broadcastToCampaign(io, Number(character.campaign_id), "gear:added", { - characterId: Number(id), - gear, - }); - - res.status(201).json(gear); + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(campaignId), "character:created", enriched); + res.status(201).json(enriched); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); + } }); -// DELETE /api/characters/:id/gear/:gearId — remove gear -router.delete("/:id/gear/:gearId", (req, res) => { - const { id, gearId } = req.params; - const character = db - .prepare("SELECT campaign_id FROM characters WHERE id = ?") - .get(id) as Record; - if (!character) { - res.status(404).json({ error: "Character not found" }); - return; +// PATCH /api/characters/:id +router.patch("/:id", async (req, res) => { + try { + const { id } = req.params; + const allowedFields = [ + "name", "class", "ancestry", "level", "xp", "hp_current", "hp_max", + "ac", "alignment", "title", "notes", "background", "deity", "languages", + "gp", "sp", "cp", "gear_slots_max", "overrides", "color", "luck_token", + "torch_lit_at", + ]; + + const updates: string[] = []; + const values: unknown[] = []; + + for (const field of allowedFields) { + if (req.body[field] !== undefined) { + updates.push(`${field} = ?`); + const val = req.body[field]; + values.push(typeof val === "object" && val !== null ? JSON.stringify(val) : val); + } + } + + if (updates.length === 0) { + res.status(400).json({ error: "No valid fields to update" }); + return; + } + + values.push(id); + await db.execute(`UPDATE characters SET ${updates.join(", ")} WHERE id = ?`, values as import("mysql2").ExecuteValues); + + const [rows] = await db.execute( + "SELECT * FROM characters WHERE id = ?", + [id] + ); + if (rows.length === 0) { + res.status(404).json({ error: "Character not found" }); + return; + } + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(rows[0].campaign_id), "character:updated", { + id: Number(id), + ...req.body, + }); + res.json(rows[0]); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); } - - const result = db - .prepare("DELETE FROM character_gear WHERE id = ? AND character_id = ?") - .run(gearId, id); - if (result.changes === 0) { - res.status(404).json({ error: "Gear not found" }); - return; - } - - const io: Server = req.app.get("io"); - broadcastToCampaign(io, Number(character.campaign_id), "gear:removed", { - characterId: Number(id), - gearId: Number(gearId), - }); - - res.status(204).end(); }); -// POST /api/characters/:id/talents — add talent -router.post("/:id/talents", (req, res) => { - const { id } = req.params; - const { name, description, effect, game_talent_id } = req.body; +// DELETE /api/characters/:id +router.delete("/:id", async (req, res) => { + try { + const [rows] = await db.execute( + "SELECT * FROM characters WHERE id = ?", + [req.params.id] + ); + if (rows.length === 0) { + res.status(404).json({ error: "Character not found" }); + return; + } - if (!name || !name.trim()) { - res.status(400).json({ error: "Talent name is required" }); - return; + await db.execute("DELETE FROM characters WHERE id = ?", [req.params.id]); + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(rows[0].campaign_id), "character:deleted", { + id: Number(req.params.id), + }); + res.status(204).end(); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); } +}); - const result = db - .prepare( - "INSERT INTO character_talents (character_id, name, description, effect, game_talent_id) VALUES (?, ?, ?, ?, ?)", - ) - .run( - id, - name.trim(), - description || "", - JSON.stringify(effect || {}), - game_talent_id ?? null, +// PATCH /api/characters/:id/stats/:statName +router.patch("/:id/stats/:statName", async (req, res) => { + try { + const { id, statName } = req.params; + const { value } = req.body; + const upper = statName.toUpperCase(); + + if (!DEFAULT_STATS.includes(upper)) { + res.status(400).json({ error: "Invalid stat name" }); + return; + } + + await db.execute( + "UPDATE character_stats SET value = ? WHERE character_id = ? AND stat_name = ?", + [value, id, upper] ); - const talentRow = db - .prepare("SELECT * FROM character_talents WHERE id = ?") - .get(result.lastInsertRowid) as Record; - const talent = { ...talentRow, effect: parseJson(talentRow.effect) }; + const [rows] = await db.execute( + "SELECT campaign_id FROM characters WHERE id = ?", + [id] + ); + if (rows.length === 0) { + res.status(404).json({ error: "Character not found" }); + return; + } - const character = db - .prepare("SELECT campaign_id FROM characters WHERE id = ?") - .get(id) as Record; - - const io: Server = req.app.get("io"); - broadcastToCampaign(io, Number(character.campaign_id), "talent:added", { - characterId: Number(id), - talent, - }); - - res.status(201).json(talent); + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(rows[0].campaign_id), "stat:updated", { + characterId: Number(id), + statName: upper, + value, + }); + res.json({ characterId: Number(id), statName: upper, value }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); + } }); -// DELETE /api/characters/:id/talents/:talentId — remove talent -router.delete("/:id/talents/:talentId", (req, res) => { - const { id, talentId } = req.params; - const character = db - .prepare("SELECT campaign_id FROM characters WHERE id = ?") - .get(id) as Record; - if (!character) { - res.status(404).json({ error: "Character not found" }); - return; +// POST /api/characters/:id/gear +router.post("/:id/gear", async (req, res) => { + try { + const { id } = req.params; + const { name, type, slot_count, properties, effects, game_item_id } = req.body; + + if (!name?.trim()) { + res.status(400).json({ error: "Gear name is required" }); + return; + } + + const [result] = await db.execute( + `INSERT INTO character_gear + (character_id, name, type, slot_count, properties, effects, game_item_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + id, name.trim(), type ?? "gear", slot_count ?? 1, + JSON.stringify(properties ?? {}), + JSON.stringify(effects ?? {}), + game_item_id ?? null, + ] + ); + + const [gearRows] = await db.execute( + "SELECT * FROM character_gear WHERE id = ?", + [result.insertId] + ); + const gear = { + ...gearRows[0], + properties: parseJson(gearRows[0].properties), + effects: parseJson(gearRows[0].effects), + }; + + const [charRows] = await db.execute( + "SELECT campaign_id FROM characters WHERE id = ?", + [id] + ); + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(charRows[0].campaign_id), "gear:added", { + characterId: Number(id), + gear, + }); + res.status(201).json(gear); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); } +}); - const result = db - .prepare("DELETE FROM character_talents WHERE id = ? AND character_id = ?") - .run(talentId, id); - if (result.changes === 0) { - res.status(404).json({ error: "Talent not found" }); - return; +// DELETE /api/characters/:id/gear/:gearId +router.delete("/:id/gear/:gearId", async (req, res) => { + try { + const { id, gearId } = req.params; + const [charRows] = await db.execute( + "SELECT campaign_id FROM characters WHERE id = ?", + [id] + ); + if (charRows.length === 0) { + res.status(404).json({ error: "Character not found" }); + return; + } + + const [result] = await db.execute( + "DELETE FROM character_gear WHERE id = ? AND character_id = ?", + [gearId, id] + ); + if (result.affectedRows === 0) { + res.status(404).json({ error: "Gear not found" }); + return; + } + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(charRows[0].campaign_id), "gear:removed", { + characterId: Number(id), + gearId: Number(gearId), + }); + res.status(204).end(); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); } +}); - const io: Server = req.app.get("io"); - broadcastToCampaign(io, Number(character.campaign_id), "talent:removed", { - characterId: Number(id), - talentId: Number(talentId), - }); +// POST /api/characters/:id/talents +router.post("/:id/talents", async (req, res) => { + try { + const { id } = req.params; + const { name, description, effect, game_talent_id } = req.body; - res.status(204).end(); + if (!name?.trim()) { + res.status(400).json({ error: "Talent name is required" }); + return; + } + + const [result] = await db.execute( + `INSERT INTO character_talents + (character_id, name, description, effect, game_talent_id) + VALUES (?, ?, ?, ?, ?)`, + [id, name.trim(), description ?? "", JSON.stringify(effect ?? {}), game_talent_id ?? null] + ); + + const [talentRows] = await db.execute( + "SELECT * FROM character_talents WHERE id = ?", + [result.insertId] + ); + const talent = { ...talentRows[0], effect: parseJson(talentRows[0].effect) }; + + const [charRows] = await db.execute( + "SELECT campaign_id FROM characters WHERE id = ?", + [id] + ); + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(charRows[0].campaign_id), "talent:added", { + characterId: Number(id), + talent, + }); + res.status(201).json(talent); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// DELETE /api/characters/:id/talents/:talentId +router.delete("/:id/talents/:talentId", async (req, res) => { + try { + const { id, talentId } = req.params; + const [charRows] = await db.execute( + "SELECT campaign_id FROM characters WHERE id = ?", + [id] + ); + if (charRows.length === 0) { + res.status(404).json({ error: "Character not found" }); + return; + } + + const [result] = await db.execute( + "DELETE FROM character_talents WHERE id = ? AND character_id = ?", + [talentId, id] + ); + if (result.affectedRows === 0) { + res.status(404).json({ error: "Talent not found" }); + return; + } + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(charRows[0].campaign_id), "talent:removed", { + characterId: Number(id), + talentId: Number(talentId), + }); + res.status(204).end(); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); + } }); export default router;