Phase 5
This commit is contained in:
@@ -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" }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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[],
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user