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

137 lines
5.7 KiB
TypeScript

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 });
});