feat: add campaign membership, invite generation, and join-by-token routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-11 00:29:42 -04:00
parent d1156745ca
commit 56d46166cd

View file

@ -1,14 +1,22 @@
import { Router } from "express"; import { Router } from "express";
import type { RowDataPacket, ResultSetHeader } from "mysql2"; import type { RowDataPacket, ResultSetHeader } from "mysql2";
import crypto from "crypto";
import db from "../db.js"; import db from "../db.js";
import { requireAuth, requireCampaignRole } from "../auth/middleware.js";
const router = Router(); const router = Router();
// GET /api/campaigns // GET /api/campaigns — only campaigns current user is a member of
router.get("/", async (_req, res) => { router.get("/", requireAuth, async (req, res) => {
try { try {
const userId = req.user!.userId;
const [rows] = await db.execute<RowDataPacket[]>( const [rows] = await db.execute<RowDataPacket[]>(
"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); res.json(rows);
} catch (err) { } catch (err) {
@ -17,31 +25,42 @@ router.get("/", async (_req, res) => {
} }
}); });
// POST /api/campaigns // POST /api/campaigns — create campaign, creator becomes DM
router.post("/", async (req, res) => { 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 { try {
const { name } = req.body; await conn.beginTransaction();
if (!name?.trim()) { const [result] = await conn.execute<ResultSetHeader>(
res.status(400).json({ error: "Campaign name is required" });
return;
}
const [result] = await db.execute<ResultSetHeader>(
"INSERT INTO campaigns (name) VALUES (?)", "INSERT INTO campaigns (name) VALUES (?)",
[name.trim()] [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<RowDataPacket[]>( const [rows] = await db.execute<RowDataPacket[]>(
"SELECT * FROM campaigns WHERE id = ?", "SELECT c.*, 'dm' as role FROM campaigns c WHERE c.id = ?",
[result.insertId] [campaignId]
); );
res.status(201).json(rows[0]); res.status(201).json(rows[0]);
} catch (err) { } catch (err) {
await conn.rollback();
console.error(err); console.error(err);
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} finally {
conn.release();
} }
}); });
// GET /api/campaigns/:id // GET /api/campaigns/:id
router.get("/:id", async (req, res) => { router.get("/:id", requireAuth, async (req, res) => {
try { try {
const [rows] = await db.execute<RowDataPacket[]>( const [rows] = await db.execute<RowDataPacket[]>(
"SELECT * FROM campaigns WHERE id = ?", "SELECT * FROM campaigns WHERE id = ?",
@ -58,8 +77,8 @@ router.get("/:id", async (req, res) => {
} }
}); });
// DELETE /api/campaigns/:id // DELETE /api/campaigns/:id — DM only
router.delete("/:id", async (req, res) => { router.delete("/:id", requireAuth, requireCampaignRole("dm"), async (req, res) => {
try { try {
const [result] = await db.execute<ResultSetHeader>( const [result] = await db.execute<ResultSetHeader>(
"DELETE FROM campaigns WHERE id = ?", "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<RowDataPacket[]>(
"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<RowDataPacket[]>(
`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<RowDataPacket[]>(
"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; export default router;