This commit is contained in:
2026-05-04 14:35:54 -05:00
parent 884043cf22
commit 11d227cedb
16 changed files with 1830 additions and 56 deletions
+2
View File
@@ -17,6 +17,7 @@ import { checkoutRouter } from "./routes/checkout.js";
import { mediaRouter } from "./routes/media.js";
import { webhooksRouter } from "./routes/webhooks.js";
import { reportingRouter } from "./routes/reporting.js";
import { paddleRaiseCampaignsRouter } from "./routes/paddle-raise-campaigns.js";
export const app = express();
@@ -70,6 +71,7 @@ app.use("/api/check-in", checkInRouter);
app.use("/api/checkout", checkoutRouter);
app.use("/api/media", mediaRouter);
app.use("/api/reporting", reportingRouter);
app.use("/api/paddle-raise", paddleRaiseCampaignsRouter);
// ── 404 fallthrough ────────────────────────────────────────────────────────────
app.use((_req, res) => res.status(404).json({ error: "Not found" }));
+2
View File
@@ -12,6 +12,7 @@ import { app } from "./app.js";
import { registerSocketHandlers } from "./socket/index.js";
import { startScheduler } from "./services/scheduler.js";
import { prisma } from "./lib/prisma.js";
import { setIO } from "./lib/io.js";
const PORT = parseInt(process.env["PORT"] ?? "3001", 10);
@@ -31,6 +32,7 @@ export const io = new Server<
},
});
setIO(io);
registerSocketHandlers(io);
startScheduler(io);
+25
View File
@@ -0,0 +1,25 @@
/**
* Singleton accessor for the Socket.io server instance.
* Set once in index.ts after the IO server is created;
* imported by webhooks and any other non-handler code that needs to emit.
*/
import type { Server } from "socket.io";
import type {
ServerToClientEvents,
ClientToServerEvents,
InterServerEvents,
SocketData,
} from "@storybid/shared";
type IO = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
let _io: IO | null = null;
export function setIO(io: IO): void {
_io = io;
}
export function getIO(): IO {
if (!_io) throw new Error("[io] IO not initialized — call setIO first");
return _io;
}
@@ -31,6 +31,12 @@ const UpdateOrgSchema = z.object({
stripeAccountId: z.string().nullable().optional(),
});
// Stripe publishable key — safe to return to any authenticated user
organizationRouter.get("/stripe-pk", requireAuth, (_req, res) => {
const key = process.env["STRIPE_PUBLISHABLE_KEY"] ?? null;
res.json({ publishableKey: key });
});
organizationRouter.patch("/", requireAuth, requireRole("admin"), async (req, res) => {
const parse = UpdateOrgSchema.safeParse(req.body);
if (!parse.success) {
@@ -0,0 +1,146 @@
/**
* GET /api/paddle-raise/campaigns?eventId= list campaigns for an event
* POST /api/paddle-raise/campaigns create campaign
* GET /api/paddle-raise/campaigns/:id get campaign with donation totals
* PATCH /api/paddle-raise/campaigns/:id update name/goal/tiers/isActive
* DELETE /api/paddle-raise/campaigns/:id soft-delete (set isActive false)
*/
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
export const paddleRaiseCampaignsRouter = Router();
const adminOnly = requireRole("admin", "event_manager");
const CampaignSchema = z.object({
eventId: z.string(),
name: z.string().min(1),
goal: z.number().positive().nullable().optional(),
tiers: z.array(z.number().positive()).default([25, 50, 100, 250, 500, 1000]),
isActive: z.boolean().default(false),
});
const UpdateCampaignSchema = CampaignSchema.partial().omit({ eventId: true });
// ── List ───────────────────────────────────────────────────────────────────────
paddleRaiseCampaignsRouter.get("/campaigns", requireAuth, async (req, res) => {
const { eventId } = req.query;
if (typeof eventId !== "string") {
res.status(400).json({ error: "eventId query param required" });
return;
}
const campaigns = await prisma.paddleRaiseCampaign.findMany({
where: { eventId },
orderBy: { createdAt: "asc" },
});
// Serialize Decimal
res.json(
campaigns.map((c) => ({
...c,
goal: c.goal ? Number(c.goal) : null,
totalRaised: Number(c.totalRaised),
tiers: c.tiers as number[],
})),
);
});
// ── Create ─────────────────────────────────────────────────────────────────────
paddleRaiseCampaignsRouter.post("/campaigns", requireAuth, adminOnly, async (req, res) => {
const parse = CampaignSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const { eventId, name, goal, tiers, isActive } = parse.data;
// Only one active campaign per event at a time
if (isActive) {
await prisma.paddleRaiseCampaign.updateMany({
where: { eventId, isActive: true },
data: { isActive: false },
});
}
const campaign = await prisma.paddleRaiseCampaign.create({
data: { eventId, name, goal: goal ?? null, tiers, isActive },
});
res.json({
...campaign,
goal: campaign.goal ? Number(campaign.goal) : null,
totalRaised: Number(campaign.totalRaised),
tiers: campaign.tiers as number[],
});
});
// ── Get one ────────────────────────────────────────────────────────────────────
paddleRaiseCampaignsRouter.get("/campaigns/:id", requireAuth, async (req, res) => {
const campaign = await prisma.paddleRaiseCampaign.findUnique({
where: { id: req.params["id"] },
include: {
donations: {
select: { id: true, amount: true, anonymous: true, bidderId: true, createdAt: true },
orderBy: { createdAt: "desc" },
},
},
});
if (!campaign) {
res.status(404).json({ error: "Campaign not found" });
return;
}
res.json({
...campaign,
goal: campaign.goal ? Number(campaign.goal) : null,
totalRaised: Number(campaign.totalRaised),
tiers: campaign.tiers as number[],
donations: campaign.donations.map((d) => ({ ...d, amount: Number(d.amount) })),
});
});
// ── Update ─────────────────────────────────────────────────────────────────────
paddleRaiseCampaignsRouter.patch("/campaigns/:id", requireAuth, adminOnly, async (req, res) => {
const parse = UpdateCampaignSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const existing = await prisma.paddleRaiseCampaign.findUnique({
where: { id: req.params["id"] },
});
if (!existing) {
res.status(404).json({ error: "Campaign not found" });
return;
}
// Deactivate others when activating this one
if (parse.data.isActive) {
await prisma.paddleRaiseCampaign.updateMany({
where: { eventId: existing.eventId, isActive: true, id: { not: existing.id } },
data: { isActive: false },
});
}
const updated = await prisma.paddleRaiseCampaign.update({
where: { id: existing.id },
data: parse.data,
});
res.json({
...updated,
goal: updated.goal ? Number(updated.goal) : null,
totalRaised: Number(updated.totalRaised),
tiers: updated.tiers as number[],
});
});
+10 -3
View File
@@ -8,6 +8,7 @@ import { Router } from "express";
import express from "express";
import Stripe from "stripe";
import { prisma } from "../lib/prisma.js";
import { getIO } from "../lib/io.js";
export const webhooksRouter = Router();
@@ -118,9 +119,15 @@ async function handlePaymentSucceeded(intent: Stripe.PaymentIntent): Promise<voi
});
if (campaign) {
console.log(
`[webhook] paddle raise ${campaignId} total → $${Number(campaign.totalRaised)}`,
);
const total = Number(campaign.totalRaised);
console.log(`[webhook] paddle raise ${campaignId} total → $${total}`);
try {
getIO()
.to(`event:${campaign.eventId}`)
.emit("paddle_raise_update", { campaignId, totalRaised: total });
} catch {
// IO may not be set in test environments
}
}
}
}