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

187 lines
6.8 KiB
TypeScript

import { Router } from "express";
import { z } from "zod";
import { db } from "../db/client.js";
import { requireAuth, requireRole, type AuthedRequest } from "../middleware.js";
import { discordService } from "../services/discordService.js";
import { permissionService } from "../services/permissionService.js";
import { auditService } from "../services/auditService.js";
const createCampaignSchema = z.object({
name: z.string().min(2),
description: z.string().default(""),
discordChannelId: z.string().min(5).optional()
});
const createPostSchema = z.object({
title: z.string().min(2),
bodyMd: z.string().min(1)
});
export const campaignRoutes = Router();
campaignRoutes.get("/campaigns", requireAuth, (req: AuthedRequest, res) => {
const campaigns = db
.prepare(
`SELECT id, name, description, status, discord_channel_id, created_at
FROM campaigns
WHERE guild_id = ?
ORDER BY created_at DESC`
)
.all(req.auth!.guildId);
res.json(campaigns);
});
campaignRoutes.post("/campaigns", requireAuth, (req: AuthedRequest, res) => {
const parsed = createCampaignSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.flatten() });
return;
}
const result = db
.prepare(
`INSERT INTO campaigns (guild_id, name, description, discord_channel_id, created_by)
VALUES (?, ?, ?, ?, ?)`
)
.run(
req.auth!.guildId,
parsed.data.name,
parsed.data.description,
parsed.data.discordChannelId ?? null,
req.auth!.userId
);
const campaignId = Number(result.lastInsertRowid);
db.prepare("INSERT INTO campaign_dms (campaign_id, user_id) VALUES (?, ?)").run(campaignId, req.auth!.userId);
// Creating a campaign makes you a DM — promote if not already dm/admin
const alreadyElevated = db
.prepare(
"SELECT id FROM memberships WHERE guild_id = ? AND user_id = ? AND role IN ('dm','admin') AND status = 'active'"
)
.get(req.auth!.guildId, req.auth!.userId);
if (!alreadyElevated) {
db.prepare(
"INSERT OR IGNORE INTO memberships (guild_id, user_id, role, status) VALUES (?, ?, 'dm', 'active')"
).run(req.auth!.guildId, req.auth!.userId);
}
auditService.log(req.auth!.guildId, req.auth!.userId, "campaign.created", { campaignId });
res.status(201).json({ id: campaignId });
});
campaignRoutes.get("/campaigns/:id", requireAuth, (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const campaign = db
.prepare("SELECT * FROM campaigns WHERE id = ? AND guild_id = ?")
.get(id, req.auth!.guildId);
if (!campaign) {
res.status(404).json({ error: "Not found" });
return;
}
const posts = db
.prepare(
`SELECT p.id, p.title, p.body_md, p.created_at, u.username AS author
FROM campaign_posts p
JOIN users u ON u.id = p.author_id
WHERE p.campaign_id = ?
ORDER BY p.created_at DESC`
)
.all(id);
const characters = db
.prepare(
`SELECT c.id, c.character_name, c.race, c.class, c.level, c.alignment, c.pronouns,
c.portrait_url, c.bio, c.notes, c.user_id, c.dndbeyond_id, c.dndbeyond_last_sync, c.stats_json, u.username
FROM characters c
JOIN users u ON u.id = c.user_id
WHERE c.campaign_id = ?
ORDER BY c.created_at DESC`
)
.all(id);
const recaps = db
.prepare(
`SELECT r.id, r.title, r.body_md, r.created_at, u.username AS author
FROM recaps r
JOIN users u ON u.id = r.author_id
WHERE r.campaign_id = ?
ORDER BY r.created_at DESC`
)
.all(id);
const today = new Date().toISOString().slice(0, 10);
const todaysGameNight = db
.prepare(
'SELECT id, scheduled_date FROM game_nights WHERE guild_id = ? AND selected_campaign_id = ? AND scheduled_date = ?'
)
.get(req.auth!.guildId, id, today) as { id: number; scheduled_date: string } | undefined;
// Is the requesting user a DM for this specific campaign?
const isCampaignDm = Boolean(
db.prepare("SELECT id FROM campaign_dms WHERE campaign_id = ? AND user_id = ?")
.get(id, req.auth!.userId)
);
res.json({ campaign, posts, characters, recaps, todaysGameNight: todaysGameNight ?? null, isCampaignDm });
});
campaignRoutes.post("/campaigns/:id/posts", requireAuth, async (req: AuthedRequest, res) => {
const campaignId = Number(req.params.id);
if (!permissionService.ensureCanManageCampaign(req.auth!.guildId, req.auth!.userId, campaignId)) {
res.status(403).json({ error: "Only campaign DMs or admins can post" });
return;
}
const parsed = createPostSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.flatten() });
return;
}
const campaign = db
.prepare("SELECT name, discord_channel_id FROM campaigns WHERE id = ? AND guild_id = ?")
.get(campaignId, req.auth!.guildId) as { name: string; discord_channel_id: string | null } | undefined;
if (!campaign) {
res.status(404).json({ error: "Campaign not found" });
return;
}
const insert = db
.prepare(
`INSERT INTO campaign_posts (campaign_id, author_id, title, body_md, visibility)
VALUES (?, ?, ?, ?, 'guild')`
)
.run(campaignId, req.auth!.userId, parsed.data.title, parsed.data.bodyMd);
const postId = Number(insert.lastInsertRowid);
let discordMessageId: string | null = null;
if (campaign.discord_channel_id) {
discordMessageId = await discordService.postToChannel(
campaign.discord_channel_id,
`**${campaign.name}**\n**${parsed.data.title}**\n${parsed.data.bodyMd}`
);
}
if (discordMessageId) {
db.prepare("UPDATE campaign_posts SET discord_message_id = ? WHERE id = ?").run(discordMessageId, postId);
}
auditService.log(req.auth!.guildId, req.auth!.userId, "campaign.post_created", { campaignId, postId });
res.status(201).json({ id: postId, discordMessageId });
});
campaignRoutes.post("/campaigns/:id/recaps", requireAuth, (req: AuthedRequest, res) => {
const campaignId = Number(req.params.id);
if (!permissionService.ensureCanManageCampaign(req.auth!.guildId, req.auth!.userId, campaignId)) {
res.status(403).json({ error: "Only campaign DMs or admins can create recaps" });
return;
}
const parsed = createPostSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.flatten() });
return;
}
const activeNight = db
.prepare("SELECT id FROM game_nights WHERE guild_id = ? ORDER BY scheduled_date DESC LIMIT 1")
.get(req.auth!.guildId) as { id: number } | undefined;
const result = db
.prepare(
"INSERT INTO recaps (campaign_id, game_night_id, author_id, title, body_md) VALUES (?, ?, ?, ?, ?)"
)
.run(campaignId, activeNight?.id ?? null, req.auth!.userId, parsed.data.title, parsed.data.bodyMd);
res.status(201).json({ id: Number(result.lastInsertRowid) });
});