From 56d46166cd6940a870b03e77836cd582a62b57a8 Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Sat, 11 Apr 2026 00:29:42 -0400 Subject: [PATCH] feat: add campaign membership, invite generation, and join-by-token routes Co-Authored-By: Claude Sonnet 4.6 --- server/src/routes/campaigns.ts | 122 ++++++++++++++++++++++++++++----- 1 file changed, 106 insertions(+), 16 deletions(-) diff --git a/server/src/routes/campaigns.ts b/server/src/routes/campaigns.ts index 4bfad16..bdf9ee4 100644 --- a/server/src/routes/campaigns.ts +++ b/server/src/routes/campaigns.ts @@ -1,14 +1,22 @@ import { Router } from "express"; import type { RowDataPacket, ResultSetHeader } from "mysql2"; +import crypto from "crypto"; import db from "../db.js"; +import { requireAuth, requireCampaignRole } from "../auth/middleware.js"; const router = Router(); -// GET /api/campaigns -router.get("/", async (_req, res) => { +// GET /api/campaigns — only campaigns current user is a member of +router.get("/", requireAuth, async (req, res) => { try { + const userId = req.user!.userId; const [rows] = await db.execute( - "SELECT * FROM campaigns ORDER BY created_at DESC" + `SELECT c.*, cm.role + FROM campaigns c + JOIN campaign_members cm ON cm.campaign_id = c.id + WHERE cm.user_id = ? + ORDER BY c.created_at DESC`, + [userId] ); res.json(rows); } catch (err) { @@ -17,31 +25,42 @@ router.get("/", async (_req, res) => { } }); -// POST /api/campaigns -router.post("/", async (req, res) => { +// POST /api/campaigns — create campaign, creator becomes DM +router.post("/", requireAuth, async (req, res) => { + const { name } = req.body; + if (!name?.trim()) { + res.status(400).json({ error: "Campaign name is required" }); + return; + } + const conn = await db.getConnection(); try { - const { name } = req.body; - if (!name?.trim()) { - res.status(400).json({ error: "Campaign name is required" }); - return; - } - const [result] = await db.execute( + await conn.beginTransaction(); + const [result] = await conn.execute( "INSERT INTO campaigns (name) VALUES (?)", [name.trim()] ); + const campaignId = result.insertId; + await conn.execute( + "INSERT INTO campaign_members (campaign_id, user_id, role) VALUES (?, ?, 'dm')", + [campaignId, req.user!.userId] + ); + await conn.commit(); const [rows] = await db.execute( - "SELECT * FROM campaigns WHERE id = ?", - [result.insertId] + "SELECT c.*, 'dm' as role FROM campaigns c WHERE c.id = ?", + [campaignId] ); res.status(201).json(rows[0]); } catch (err) { + await conn.rollback(); console.error(err); res.status(500).json({ error: "Internal server error" }); + } finally { + conn.release(); } }); // GET /api/campaigns/:id -router.get("/:id", async (req, res) => { +router.get("/:id", requireAuth, async (req, res) => { try { const [rows] = await db.execute( "SELECT * FROM campaigns WHERE id = ?", @@ -58,8 +77,8 @@ router.get("/:id", async (req, res) => { } }); -// DELETE /api/campaigns/:id -router.delete("/:id", async (req, res) => { +// DELETE /api/campaigns/:id — DM only +router.delete("/:id", requireAuth, requireCampaignRole("dm"), async (req, res) => { try { const [result] = await db.execute( "DELETE FROM campaigns WHERE id = ?", @@ -76,4 +95,75 @@ router.delete("/:id", async (req, res) => { } }); +// GET /api/campaigns/:id/my-role — returns current user's role in this campaign +router.get("/:id/my-role", requireAuth, async (req, res) => { + try { + const [rows] = await db.execute( + "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", + [req.params.id, req.user!.userId] + ); + if (rows.length === 0) { + res.status(403).json({ error: "Not a campaign member" }); + return; + } + res.json({ role: rows[0].role }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// POST /api/campaigns/:id/invite — generate invite link (DM only) +router.post("/:id/invite", requireAuth, requireCampaignRole("dm"), async (req, res) => { + try { + const token = crypto.randomBytes(32).toString("hex"); + await db.execute( + "INSERT INTO campaign_invites (campaign_id, token, created_by) VALUES (?, ?, ?)", + [req.params.id, token, req.user!.userId] + ); + const clientUrl = process.env.CLIENT_URL ?? "http://localhost:5173"; + res.json({ url: `${clientUrl}/join/${token}` }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// POST /api/campaigns/join/:token — join campaign as player +router.post("/join/:token", requireAuth, async (req, res) => { + try { + const [inviteRows] = await db.execute( + `SELECT * FROM campaign_invites + WHERE token = ? AND (expires_at IS NULL OR expires_at > NOW())`, + [req.params.token] + ); + if (inviteRows.length === 0) { + res.status(404).json({ error: "Invite not found or expired" }); + return; + } + + const campaignId = inviteRows[0].campaign_id as number; + const userId = req.user!.userId; + + // Already a member? Return 200 silently + const [existing] = await db.execute( + "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", + [campaignId, userId] + ); + if (existing.length > 0) { + res.json({ campaignId, role: existing[0].role }); + return; + } + + await db.execute( + "INSERT INTO campaign_members (campaign_id, user_id, role) VALUES (?, ?, 'player')", + [campaignId, userId] + ); + res.json({ campaignId, role: "player" }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Internal server error" }); + } +}); + export default router;