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