- Copied as-is from learning_voice_ai_agent/services/growth-service - 33 tests passing (vitest) - Fastify 5 + Cosmos DB + Stripe + Zod - Modules: invitations, referrals, promos - Port 4001
125 lines
4.2 KiB
TypeScript
125 lines
4.2 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
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<ReferralDoc> = { ...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;
|
|
});
|
|
}
|