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 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-11 00:14:49 -04:00
parent 2812d81979
commit 385d9b6e9e

View file

@ -1,8 +1,10 @@
import { Router } from "express"; import { Router } from "express";
import type { ParamsDictionary } from "express-serve-static-core"; import type { ParamsDictionary } from "express-serve-static-core";
import type { RowDataPacket, ResultSetHeader } from "mysql2";
import type { Server } from "socket.io"; import type { Server } from "socket.io";
import db from "../db.js"; import db from "../db.js";
import { broadcastToCampaign } from "../socket.js"; import { broadcastToCampaign } from "../socket.js";
import { parseJson } from "../utils/parseJson.js";
type CampaignParams = ParamsDictionary & { campaignId: string }; type CampaignParams = ParamsDictionary & { campaignId: string };
@ -15,18 +17,7 @@ function generateCharacterColor(): string {
return `hsl(${hue}, 60%, 65%)`; return `hsl(${hue}, 60%, 65%)`;
} }
function parseJson(val: unknown): Record<string, unknown> { function parseGear(rows: RowDataPacket[]) {
if (typeof val === "string") {
try {
return JSON.parse(val);
} catch {
return {};
}
}
return (val as Record<string, unknown>) ?? {};
}
function parseGear(rows: Array<Record<string, unknown>>) {
return rows.map((r) => ({ return rows.map((r) => ({
...r, ...r,
properties: parseJson(r.properties), properties: parseJson(r.properties),
@ -34,122 +25,117 @@ function parseGear(rows: Array<Record<string, unknown>>) {
})); }));
} }
function parseTalents(rows: Array<Record<string, unknown>>) { function parseTalents(rows: RowDataPacket[]) {
return rows.map((r) => ({ ...r, effect: parseJson(r.effect) })); return rows.map((r) => ({ ...r, effect: parseJson(r.effect) }));
} }
// GET /api/campaigns/:campaignId/characters — list characters in a campaign async function enrichCharacters(characters: RowDataPacket[]) {
router.get<CampaignParams>("/", (req, res) => { return Promise.all(
const { campaignId } = req.params; characters.map(async (char) => {
const characters = db const [stats] = await db.execute<RowDataPacket[]>(
.prepare("SELECT * FROM characters WHERE campaign_id = ? ORDER BY name")
.all(campaignId) as Array<Record<string, unknown>>;
const stmtStats = db.prepare(
"SELECT stat_name, value FROM character_stats WHERE character_id = ?", "SELECT stat_name, value FROM character_stats WHERE character_id = ?",
[char.id]
); );
const stmtGear = db.prepare( const [gear] = await db.execute<RowDataPacket[]>(
"SELECT * FROM character_gear WHERE character_id = ?", "SELECT * FROM character_gear WHERE character_id = ?",
[char.id]
); );
const stmtTalents = db.prepare( const [talents] = await db.execute<RowDataPacket[]>(
"SELECT * FROM character_talents WHERE character_id = ?", "SELECT * FROM character_talents WHERE character_id = ?",
[char.id]
); );
return {
const enriched = characters.map((char) => ({
...char, ...char,
overrides: parseJson(char.overrides), overrides: parseJson(char.overrides),
stats: stmtStats.all(char.id), stats,
gear: parseGear(stmtGear.all(char.id) as Array<Record<string, unknown>>), gear: parseGear(gear),
talents: parseTalents( talents: parseTalents(talents),
stmtTalents.all(char.id) as Array<Record<string, unknown>>, };
), })
})); );
}
// GET /api/campaigns/:campaignId/characters
router.get<CampaignParams>("/", async (req, res) => {
try {
const { campaignId } = req.params;
const [characters] = await db.execute<RowDataPacket[]>(
"SELECT * FROM characters WHERE campaign_id = ? ORDER BY name",
[campaignId]
);
const enriched = await enrichCharacters(characters);
res.json(enriched); res.json(enriched);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Internal server error" });
}
}); });
// POST /api/campaigns/:campaignId/characters — create a character // POST /api/campaigns/:campaignId/characters
router.post<CampaignParams>("/", (req, res) => { router.post<CampaignParams>("/", async (req, res) => {
try {
const { campaignId } = req.params; const { campaignId } = req.params;
const { name, class: charClass, ancestry, hp_max } = req.body; const { name, class: charClass, ancestry, hp_max } = req.body;
if (!name || !name.trim()) { if (!name?.trim()) {
res.status(400).json({ error: "Character name is required" }); res.status(400).json({ error: "Character name is required" });
return; return;
} }
const insertChar = db.prepare(` const [result] = await db.execute<ResultSetHeader>(
INSERT INTO characters (campaign_id, name, class, ancestry, hp_current, hp_max, color) `INSERT INTO characters
VALUES (?, ?, ?, ?, ?, ?, ?) (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, campaignId,
name.trim(), name.trim(),
charClass || "Fighter", charClass ?? "Fighter",
ancestry || "Human", ancestry ?? "Human",
hp_max || 0, hp_max ?? 0,
hp_max || 0, hp_max ?? 0,
generateCharacterColor(), generateCharacterColor(),
]
); );
const characterId = result.lastInsertRowid; const characterId = result.insertId;
for (const stat of DEFAULT_STATS) { await Promise.all(
insertStat.run(characterId, stat); DEFAULT_STATS.map((stat) =>
} db.execute(
"INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, 10)",
const character = db [characterId, stat]
.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 [charRows] = await db.execute<RowDataPacket[]>(
"SELECT * FROM characters WHERE id = ?",
[characterId]
);
const enriched = { const enriched = {
...(character as Record<string, unknown>), ...charRows[0],
overrides: {}, overrides: {},
stats, stats: DEFAULT_STATS.map((s) => ({ stat_name: s, value: 10 })),
gear: [], gear: [],
talents: [], talents: [],
}; };
const io: Server = req.app.get("io"); const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(campaignId), "character:created", enriched); broadcastToCampaign(io, Number(campaignId), "character:created", enriched);
res.status(201).json(enriched); res.status(201).json(enriched);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Internal server error" });
}
}); });
// PATCH /api/characters/:id — update character fields // PATCH /api/characters/:id
router.patch("/:id", (req, res) => { router.patch("/:id", async (req, res) => {
try {
const { id } = req.params; const { id } = req.params;
const allowedFields = [ const allowedFields = [
"name", "name", "class", "ancestry", "level", "xp", "hp_current", "hp_max",
"class", "ac", "alignment", "title", "notes", "background", "deity", "languages",
"ancestry", "gp", "sp", "cp", "gear_slots_max", "overrides", "color", "luck_token",
"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", "torch_lit_at",
]; ];
@ -160,10 +146,7 @@ router.patch("/:id", (req, res) => {
if (req.body[field] !== undefined) { if (req.body[field] !== undefined) {
updates.push(`${field} = ?`); updates.push(`${field} = ?`);
const val = req.body[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);
values.push(
typeof val === "object" && val !== null ? JSON.stringify(val) : val,
);
} }
} }
@ -173,49 +156,57 @@ router.patch("/:id", (req, res) => {
} }
values.push(id); values.push(id);
db.prepare(`UPDATE characters SET ${updates.join(", ")} WHERE id = ?`).run( await db.execute(`UPDATE characters SET ${updates.join(", ")} WHERE id = ?`, values as import("mysql2").ExecuteValues);
...values,
);
const character = db const [rows] = await db.execute<RowDataPacket[]>(
.prepare("SELECT * FROM characters WHERE id = ?") "SELECT * FROM characters WHERE id = ?",
.get(id) as Record<string, unknown>; [id]
if (!character) { );
if (rows.length === 0) {
res.status(404).json({ error: "Character not found" }); res.status(404).json({ error: "Character not found" });
return; return;
} }
const io: Server = req.app.get("io"); const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "character:updated", { broadcastToCampaign(io, Number(rows[0].campaign_id), "character:updated", {
id: Number(id), id: Number(id),
...req.body, ...req.body,
}); });
res.json(rows[0]);
res.json(character); } catch (err) {
console.error(err);
res.status(500).json({ error: "Internal server error" });
}
}); });
// DELETE /api/characters/:id — delete a character // DELETE /api/characters/:id
router.delete("/:id", (req, res) => { router.delete("/:id", async (req, res) => {
const character = db try {
.prepare("SELECT * FROM characters WHERE id = ?") const [rows] = await db.execute<RowDataPacket[]>(
.get(req.params.id) as Record<string, unknown> | undefined; "SELECT * FROM characters WHERE id = ?",
if (!character) { [req.params.id]
);
if (rows.length === 0) {
res.status(404).json({ error: "Character not found" }); res.status(404).json({ error: "Character not found" });
return; return;
} }
db.prepare("DELETE FROM characters WHERE id = ?").run(req.params.id); await db.execute("DELETE FROM characters WHERE id = ?", [req.params.id]);
const io: Server = req.app.get("io"); const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "character:deleted", { broadcastToCampaign(io, Number(rows[0].campaign_id), "character:deleted", {
id: Number(req.params.id), id: Number(req.params.id),
}); });
res.status(204).end(); res.status(204).end();
} catch (err) {
console.error(err);
res.status(500).json({ error: "Internal server error" });
}
}); });
// PATCH /api/characters/:id/stats/:statName — update a single stat // PATCH /api/characters/:id/stats/:statName
router.patch("/:id/stats/:statName", (req, res) => { router.patch("/:id/stats/:statName", async (req, res) => {
try {
const { id, statName } = req.params; const { id, statName } = req.params;
const { value } = req.body; const { value } = req.body;
const upper = statName.toUpperCase(); const upper = statName.toUpperCase();
@ -225,169 +216,188 @@ router.patch("/:id/stats/:statName", (req, res) => {
return; return;
} }
db.prepare( await db.execute(
"UPDATE character_stats SET value = ? WHERE character_id = ? AND stat_name = ?", "UPDATE character_stats SET value = ? WHERE character_id = ? AND stat_name = ?",
).run(value, id, upper); [value, id, upper]
);
const character = db const [rows] = await db.execute<RowDataPacket[]>(
.prepare("SELECT campaign_id FROM characters WHERE id = ?") "SELECT campaign_id FROM characters WHERE id = ?",
.get(id) as Record<string, unknown>; [id]
if (!character) { );
if (rows.length === 0) {
res.status(404).json({ error: "Character not found" }); res.status(404).json({ error: "Character not found" });
return; return;
} }
const io: Server = req.app.get("io"); const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "stat:updated", { broadcastToCampaign(io, Number(rows[0].campaign_id), "stat:updated", {
characterId: Number(id), characterId: Number(id),
statName: upper, statName: upper,
value, value,
}); });
res.json({ 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" });
}
}); });
// POST /api/characters/:id/gear — add gear // POST /api/characters/:id/gear
router.post("/:id/gear", (req, res) => { router.post("/:id/gear", async (req, res) => {
try {
const { id } = req.params; const { id } = req.params;
const { name, type, slot_count, properties, effects, game_item_id } = const { name, type, slot_count, properties, effects, game_item_id } = req.body;
req.body;
if (!name || !name.trim()) { if (!name?.trim()) {
res.status(400).json({ error: "Gear name is required" }); res.status(400).json({ error: "Gear name is required" });
return; return;
} }
const result = db const [result] = await db.execute<ResultSetHeader>(
.prepare( `INSERT INTO character_gear
"INSERT INTO character_gear (character_id, name, type, slot_count, properties, effects, game_item_id) VALUES (?, ?, ?, ?, ?, ?, ?)", (character_id, name, type, slot_count, properties, effects, game_item_id)
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
.run( [
id, id, name.trim(), type ?? "gear", slot_count ?? 1,
name.trim(), JSON.stringify(properties ?? {}),
type || "gear", JSON.stringify(effects ?? {}),
slot_count ?? 1,
JSON.stringify(properties || {}),
JSON.stringify(effects || {}),
game_item_id ?? null, game_item_id ?? null,
]
); );
const gearRow = db const [gearRows] = await db.execute<RowDataPacket[]>(
.prepare("SELECT * FROM character_gear WHERE id = ?") "SELECT * FROM character_gear WHERE id = ?",
.get(result.lastInsertRowid) as Record<string, unknown>; [result.insertId]
);
const gear = { const gear = {
...gearRow, ...gearRows[0],
properties: parseJson(gearRow.properties), properties: parseJson(gearRows[0].properties),
effects: parseJson(gearRow.effects), effects: parseJson(gearRows[0].effects),
}; };
const character = db const [charRows] = await db.execute<RowDataPacket[]>(
.prepare("SELECT campaign_id FROM characters WHERE id = ?") "SELECT campaign_id FROM characters WHERE id = ?",
.get(id) as Record<string, unknown>; [id]
);
const io: Server = req.app.get("io"); const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "gear:added", { broadcastToCampaign(io, Number(charRows[0].campaign_id), "gear:added", {
characterId: Number(id), characterId: Number(id),
gear, gear,
}); });
res.status(201).json(gear); res.status(201).json(gear);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Internal server error" });
}
}); });
// DELETE /api/characters/:id/gear/:gearId — remove gear // DELETE /api/characters/:id/gear/:gearId
router.delete("/:id/gear/:gearId", (req, res) => { router.delete("/:id/gear/:gearId", async (req, res) => {
try {
const { id, gearId } = req.params; const { id, gearId } = req.params;
const character = db const [charRows] = await db.execute<RowDataPacket[]>(
.prepare("SELECT campaign_id FROM characters WHERE id = ?") "SELECT campaign_id FROM characters WHERE id = ?",
.get(id) as Record<string, unknown>; [id]
if (!character) { );
if (charRows.length === 0) {
res.status(404).json({ error: "Character not found" }); res.status(404).json({ error: "Character not found" });
return; return;
} }
const result = db const [result] = await db.execute<ResultSetHeader>(
.prepare("DELETE FROM character_gear WHERE id = ? AND character_id = ?") "DELETE FROM character_gear WHERE id = ? AND character_id = ?",
.run(gearId, id); [gearId, id]
if (result.changes === 0) { );
if (result.affectedRows === 0) {
res.status(404).json({ error: "Gear not found" }); res.status(404).json({ error: "Gear not found" });
return; return;
} }
const io: Server = req.app.get("io"); const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "gear:removed", { broadcastToCampaign(io, Number(charRows[0].campaign_id), "gear:removed", {
characterId: Number(id), characterId: Number(id),
gearId: Number(gearId), gearId: Number(gearId),
}); });
res.status(204).end(); res.status(204).end();
} catch (err) {
console.error(err);
res.status(500).json({ error: "Internal server error" });
}
}); });
// POST /api/characters/:id/talents — add talent // POST /api/characters/:id/talents
router.post("/:id/talents", (req, res) => { router.post("/:id/talents", async (req, res) => {
try {
const { id } = req.params; const { id } = req.params;
const { name, description, effect, game_talent_id } = req.body; const { name, description, effect, game_talent_id } = req.body;
if (!name || !name.trim()) { if (!name?.trim()) {
res.status(400).json({ error: "Talent name is required" }); res.status(400).json({ error: "Talent name is required" });
return; return;
} }
const result = db const [result] = await db.execute<ResultSetHeader>(
.prepare( `INSERT INTO character_talents
"INSERT INTO character_talents (character_id, name, description, effect, game_talent_id) VALUES (?, ?, ?, ?, ?)", (character_id, name, description, effect, game_talent_id)
) VALUES (?, ?, ?, ?, ?)`,
.run( [id, name.trim(), description ?? "", JSON.stringify(effect ?? {}), game_talent_id ?? null]
id,
name.trim(),
description || "",
JSON.stringify(effect || {}),
game_talent_id ?? null,
); );
const talentRow = db const [talentRows] = await db.execute<RowDataPacket[]>(
.prepare("SELECT * FROM character_talents WHERE id = ?") "SELECT * FROM character_talents WHERE id = ?",
.get(result.lastInsertRowid) as Record<string, unknown>; [result.insertId]
const talent = { ...talentRow, effect: parseJson(talentRow.effect) }; );
const talent = { ...talentRows[0], effect: parseJson(talentRows[0].effect) };
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
const [charRows] = await db.execute<RowDataPacket[]>(
"SELECT campaign_id FROM characters WHERE id = ?",
[id]
);
const io: Server = req.app.get("io"); const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "talent:added", { broadcastToCampaign(io, Number(charRows[0].campaign_id), "talent:added", {
characterId: Number(id), characterId: Number(id),
talent, talent,
}); });
res.status(201).json(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 — remove talent // DELETE /api/characters/:id/talents/:talentId
router.delete("/:id/talents/:talentId", (req, res) => { router.delete("/:id/talents/:talentId", async (req, res) => {
try {
const { id, talentId } = req.params; const { id, talentId } = req.params;
const character = db const [charRows] = await db.execute<RowDataPacket[]>(
.prepare("SELECT campaign_id FROM characters WHERE id = ?") "SELECT campaign_id FROM characters WHERE id = ?",
.get(id) as Record<string, unknown>; [id]
if (!character) { );
if (charRows.length === 0) {
res.status(404).json({ error: "Character not found" }); res.status(404).json({ error: "Character not found" });
return; return;
} }
const result = db const [result] = await db.execute<ResultSetHeader>(
.prepare("DELETE FROM character_talents WHERE id = ? AND character_id = ?") "DELETE FROM character_talents WHERE id = ? AND character_id = ?",
.run(talentId, id); [talentId, id]
if (result.changes === 0) { );
if (result.affectedRows === 0) {
res.status(404).json({ error: "Talent not found" }); res.status(404).json({ error: "Talent not found" });
return; return;
} }
const io: Server = req.app.get("io"); const io: Server = req.app.get("io");
broadcastToCampaign(io, Number(character.campaign_id), "talent:removed", { broadcastToCampaign(io, Number(charRows[0].campaign_id), "talent:removed", {
characterId: Number(id), characterId: Number(id),
talentId: Number(talentId), talentId: Number(talentId),
}); });
res.status(204).end(); res.status(204).end();
} catch (err) {
console.error(err);
res.status(500).json({ error: "Internal server error" });
}
}); });
export default router; export default router;