/** * Referral REST endpoints. * * GET /referrals — list all referrals * GET /referrals/stats — count stats (total, completed, rewarded) * GET /referrals/by-referrer/:referrerId — list by referrer * GET /referrals/by-email/:email — get by referred email * POST /referrals — create a referral * PUT /referrals/:id — update status */ import type { FastifyInstance } from "fastify"; import { PRODUCT_ID } from "../../lib/product-config.js"; import { BadRequestError, NotFoundError } from "../../lib/errors.js"; import { dispatchReferralStatusChanged } from "../../lib/webhooks.js"; import * as repo from "./repository.js"; import { CreateReferralSchema, UpdateReferralStatusSchema, type ReferralDoc, } from "./types.js"; export async function referralRoutes(app: FastifyInstance) { // List all app.get("/referrals", async (req) => { const { limit = "100", offset = "0" } = req.query as Record; const items = await repo.listAll(Number(limit), Number(offset)); return { referrals: items, count: items.length }; }); // Stats app.get("/referrals/stats", async () => { return repo.countReferrals(); }); // By referrer app.get("/referrals/by-referrer/:referrerId", async (req) => { const { referrerId } = req.params as { referrerId: string }; const items = await repo.getByReferrer(referrerId); return { referrals: items, count: items.length }; }); // By referred email app.get("/referrals/by-email/:email", async (req) => { const { email } = req.params as { email: string }; const doc = await repo.getByReferredEmail(email); if (!doc) throw new NotFoundError("Referral not found"); return doc; }); // Create app.post("/referrals", async (req, reply) => { const parsed = CreateReferralSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); } const input = parsed.data; // Check if referral already exists for this email const existing = await repo.getByReferredEmail(input.referredEmail); if (existing) { throw new BadRequestError("A referral already exists for this email"); } const now = new Date().toISOString(); const doc: ReferralDoc = { id: `ref_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, productId: PRODUCT_ID, referrerId: input.referrerId, referrerEmail: input.referrerEmail, referredUserId: null, referredEmail: input.referredEmail.toLowerCase(), status: "pending", referrerRewardTokens: input.referrerRewardTokens, referredRewardTokens: input.referredRewardTokens, referrerRewarded: false, referredRewarded: false, createdAt: now, completedAt: null, }; const created = await repo.create(doc); reply.code(201); return created; }); // Update status app.put("/referrals/:id", async (req) => { const { id } = req.params as { id: string }; const { referrerId } = req.query as { referrerId?: string }; if (!referrerId) { throw new BadRequestError("referrerId query parameter is required"); } const parsed = UpdateReferralStatusSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); } const updates: Partial = { ...parsed.data }; if ( parsed.data.status === "rewarded" || parsed.data.status === "subscribed" || parsed.data.status === "signed_up" ) { updates.completedAt = updates.completedAt ?? new Date().toISOString(); } const updated = await repo.update(id, referrerId, updates); if (!updated) throw new NotFoundError("Referral not found"); // Fire-and-forget webhook callback on status transition dispatchReferralStatusChanged({ referralId: updated.id, referrerId: updated.referrerId, referredEmail: updated.referredEmail, previousStatus: parsed.data.status, // requested status newStatus: updated.status, referrerRewarded: updated.referrerRewarded, referredRewarded: updated.referredRewarded, }); return updated; }); }