187 lines
6.8 KiB
TypeScript
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) });
|
|
});
|
|
|