feat(services): add growth-service (invitations, referrals, promos)

- 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
This commit is contained in:
saravanakumardb1 2026-02-12 11:39:11 -08:00
parent fc5f2bf296
commit b94510aeb9
23 changed files with 1465 additions and 0 deletions

2
services/growth-service/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

View File

@ -0,0 +1,16 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ src/
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 4001
CMD ["node", "dist/server.js"]

View File

@ -0,0 +1,30 @@
{
"name": "@lysnrai/growth-service",
"version": "0.1.0",
"private": true,
"description": "Growth Service — invitations, referrals, promo codes",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src/"
},
"dependencies": {
"@azure/cosmos": "^4.2.0",
"fastify": "^5.2.1",
"@fastify/cors": "^10.0.2",
"@fastify/swagger": "^9.4.2",
"fastify-metrics": "^10.3.0",
"stripe": "^17.5.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.12.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

View File

@ -0,0 +1,24 @@
import { z } from "zod";
const envSchema = z.object({
// Server
PORT: z.coerce.number().default(4001),
HOST: z.string().default("0.0.0.0"),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
CORS_ORIGIN: z.string().optional(),
SERVICE_NAME: z.string().default("growth-service"),
// Database
COSMOS_ENDPOINT: z.string().min(1, "COSMOS_ENDPOINT is required"),
COSMOS_KEY: z.string().min(1, "COSMOS_KEY is required"),
COSMOS_DATABASE: z.string().default("lysnrai"),
// Stripe
STRIPE_SECRET_KEY: z.string().min(1, "STRIPE_SECRET_KEY is required"),
// Webhooks
WEBHOOK_INVITATION_REDEEMED_URL: z.string().optional(),
WEBHOOK_REFERRAL_STATUS_URL: z.string().optional(),
});
export const config = envSchema.parse(process.env);

View File

@ -0,0 +1,24 @@
/**
* Shared Cosmos DB client for the Growth Service.
*/
import { CosmosClient, Container } from "@azure/cosmos";
let client: CosmosClient | null = null;
function getClient(): CosmosClient {
if (!client) {
const endpoint = process.env.COSMOS_ENDPOINT;
const key = process.env.COSMOS_KEY;
if (!endpoint || !key) {
throw new Error("COSMOS_ENDPOINT and COSMOS_KEY must be set");
}
client = new CosmosClient({ endpoint, key });
}
return client;
}
export function getContainer(name: string): Container {
const database = process.env.COSMOS_DATABASE || "lysnrai";
return getClient().database(database).container(name);
}

View File

@ -0,0 +1,43 @@
/**
* Unit tests for error types.
*/
import { describe, it, expect } from "vitest";
import { ServiceError, NotFoundError, BadRequestError, ForbiddenError } from "./errors.js";
describe("ServiceError", () => {
it("creates error with status code", () => {
const err = new ServiceError(422, "Validation failed");
expect(err.statusCode).toBe(422);
expect(err.message).toBe("Validation failed");
expect(err.name).toBe("ServiceError");
expect(err instanceof Error).toBe(true);
});
});
describe("NotFoundError", () => {
it("has 404 status", () => {
const err = new NotFoundError();
expect(err.statusCode).toBe(404);
expect(err.message).toBe("Not found");
});
it("accepts custom message", () => {
const err = new NotFoundError("Item not found");
expect(err.message).toBe("Item not found");
});
});
describe("BadRequestError", () => {
it("has 400 status", () => {
const err = new BadRequestError();
expect(err.statusCode).toBe(400);
});
});
describe("ForbiddenError", () => {
it("has 403 status", () => {
const err = new ForbiddenError();
expect(err.statusCode).toBe(403);
});
});

View File

@ -0,0 +1,34 @@
/**
* Typed service errors for consistent HTTP error responses.
*/
export class ServiceError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = "ServiceError";
}
}
export class NotFoundError extends ServiceError {
constructor(message = "Not found") {
super(404, message);
this.name = "NotFoundError";
}
}
export class BadRequestError extends ServiceError {
constructor(message = "Bad request") {
super(400, message);
this.name = "BadRequestError";
}
}
export class ForbiddenError extends ServiceError {
constructor(message = "Forbidden") {
super(403, message);
this.name = "ForbiddenError";
}
}

View File

@ -0,0 +1,10 @@
/**
* Centralized product identity single source of truth.
*
* NOTE: The canonical source is shared/product.json at the repo root.
* These values must stay in sync with that file.
*/
export const PRODUCT_ID = "lysnrai";
export const DISPLAY_NAME = "LysnrAI";
export const LICENSE_PREFIX = "LYSNR";

View File

@ -0,0 +1,69 @@
/**
* Webhook dispatcher fire-and-forget POST to a configurable callback URL.
*
* Products register webhook URLs via env vars:
* WEBHOOK_INVITATION_REDEEMED_URL called after an invitation code is redeemed
* WEBHOOK_REFERRAL_STATUS_URL called when a referral status transitions
*
* Payloads are JSON; failures are logged but never block the caller.
*/
import { PRODUCT_ID } from "./product-config.js";
export interface WebhookPayload {
event: string;
productId: string;
timestamp: string;
data: Record<string, unknown>;
}
/**
* POST a webhook payload to the given URL. Fire-and-forget: errors are logged,
* never thrown. Returns true if the POST succeeded (2xx), false otherwise.
*/
export async function dispatchWebhook(
url: string | undefined,
event: string,
data: Record<string, unknown>,
): Promise<boolean> {
if (!url) return false;
const payload: WebhookPayload = {
event,
productId: PRODUCT_ID,
timestamp: new Date().toISOString(),
data,
};
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5_000),
});
return res.ok;
} catch (err) {
// Log but never block — use stderr for structured output
process.stderr.write(`[webhook] Failed to dispatch ${event} to ${url}: ${err}\n`);
return false;
}
}
/** Dispatch an invitation.redeemed webhook. */
export function dispatchInvitationRedeemed(data: Record<string, unknown>): Promise<boolean> {
return dispatchWebhook(
process.env.WEBHOOK_INVITATION_REDEEMED_URL,
"invitation.redeemed",
data,
);
}
/** Dispatch a referral.status_changed webhook. */
export function dispatchReferralStatusChanged(data: Record<string, unknown>): Promise<boolean> {
return dispatchWebhook(
process.env.WEBHOOK_REFERRAL_STATUS_URL,
"referral.status_changed",
data,
);
}

View File

@ -0,0 +1,124 @@
/**
* Unit tests for invitations module types + validation.
*/
import { describe, it, expect } from "vitest";
import {
CreateInvitationSchema,
UpdateInvitationSchema,
RedeemInvitationSchema,
} from "./types.js";
describe("CreateInvitationSchema", () => {
it("accepts valid input with required fields", () => {
const result = CreateInvitationSchema.safeParse({
code: "WELCOME2026",
createdBy: "admin@lysnr.ai",
grantPlan: "pro",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.code).toBe("WELCOME2026");
expect(result.data.grantPlan).toBe("pro");
expect(result.data.grantTrialDays).toBe(0);
expect(result.data.bonusTokens).toBe(0);
expect(result.data.maxUses).toBe(1);
expect(result.data.expiresAt).toBeNull();
}
});
it("accepts valid input with all fields", () => {
const result = CreateInvitationSchema.safeParse({
code: "VIP-ENT",
createdBy: "admin@lysnr.ai",
grantPlan: "enterprise",
grantTrialDays: 30,
bonusTokens: 5000,
maxUses: 50,
expiresAt: "2026-12-31T00:00:00Z",
description: "VIP enterprise trial",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.grantTrialDays).toBe(30);
expect(result.data.bonusTokens).toBe(5000);
expect(result.data.maxUses).toBe(50);
}
});
it("rejects missing code", () => {
const result = CreateInvitationSchema.safeParse({
createdBy: "admin@lysnr.ai",
grantPlan: "pro",
});
expect(result.success).toBe(false);
});
it("rejects invalid grantPlan", () => {
const result = CreateInvitationSchema.safeParse({
code: "TEST",
createdBy: "admin",
grantPlan: "free",
});
expect(result.success).toBe(false);
});
it("rejects code shorter than 3 chars", () => {
const result = CreateInvitationSchema.safeParse({
code: "AB",
createdBy: "admin",
grantPlan: "pro",
});
expect(result.success).toBe(false);
});
it("rejects negative bonusTokens", () => {
const result = CreateInvitationSchema.safeParse({
code: "TEST",
createdBy: "admin",
grantPlan: "pro",
bonusTokens: -1,
});
expect(result.success).toBe(false);
});
});
describe("UpdateInvitationSchema", () => {
it("accepts partial updates", () => {
const result = UpdateInvitationSchema.safeParse({
description: "Updated description",
status: "disabled",
});
expect(result.success).toBe(true);
});
it("accepts empty object (no updates)", () => {
const result = UpdateInvitationSchema.safeParse({});
expect(result.success).toBe(true);
});
it("rejects invalid status", () => {
const result = UpdateInvitationSchema.safeParse({ status: "deleted" });
expect(result.success).toBe(false);
});
});
describe("RedeemInvitationSchema", () => {
it("accepts valid redeem input", () => {
const result = RedeemInvitationSchema.safeParse({
code: "WELCOME2026",
userId: "user_123",
});
expect(result.success).toBe(true);
});
it("rejects missing userId", () => {
const result = RedeemInvitationSchema.safeParse({ code: "WELCOME2026" });
expect(result.success).toBe(false);
});
it("rejects empty code", () => {
const result = RedeemInvitationSchema.safeParse({ code: "", userId: "user_123" });
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,108 @@
/**
* Invitations repository Cosmos DB CRUD operations.
* Consolidated from admin-dashboard-web + user-dashboard-web repos.
*/
import { getContainer } from "../../lib/cosmos.js";
import { PRODUCT_ID } from "../../lib/product-config.js";
import type { InvitationCodeDoc } from "./types.js";
const CONTAINER = "invitation_codes";
function container() {
return getContainer(CONTAINER);
}
export async function list(limit = 100, offset = 0): Promise<InvitationCodeDoc[]> {
const { resources } = await container().items
.query<InvitationCodeDoc>({
query:
"SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit",
parameters: [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@offset", value: offset },
{ name: "@limit", value: limit },
],
})
.fetchAll();
return resources;
}
export async function getById(id: string): Promise<InvitationCodeDoc | null> {
try {
const { resource } = await container().item(id, id).read<InvitationCodeDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function getByCode(code: string): Promise<InvitationCodeDoc | null> {
const { resources } = await container().items
.query<InvitationCodeDoc>({
query: "SELECT * FROM c WHERE c.productId = @productId AND c.code = @code",
parameters: [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@code", value: code.toUpperCase() },
],
})
.fetchAll();
return resources[0] ?? null;
}
export async function create(doc: InvitationCodeDoc): Promise<InvitationCodeDoc> {
const { resource } = await container().items.create(doc);
return resource as InvitationCodeDoc;
}
export async function update(
id: string,
updates: Partial<InvitationCodeDoc>,
): Promise<InvitationCodeDoc | null> {
try {
const { resource: existing } = await container().item(id, id).read<InvitationCodeDoc>();
if (!existing) return null;
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await container().item(id, id).replace(merged);
return resource as InvitationCodeDoc;
} catch {
return null;
}
}
export async function redeem(
code: string,
userId: string,
): Promise<InvitationCodeDoc | null> {
const doc = await getByCode(code);
if (!doc) return null;
if (doc.status !== "active") return null;
if (doc.currentUses >= doc.maxUses) return null;
if (doc.expiresAt && new Date(doc.expiresAt) < new Date()) return null;
if (doc.redeemedBy.includes(userId)) return null;
return update(doc.id, {
currentUses: doc.currentUses + 1,
redeemedBy: [...doc.redeemedBy, userId],
...(doc.currentUses + 1 >= doc.maxUses && { status: "expired" as const }),
});
}
export async function remove(id: string): Promise<boolean> {
try {
await container().item(id, id).delete();
return true;
} catch {
return false;
}
}
export async function count(): Promise<number> {
const { resources } = await container().items
.query<number>({
query: "SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId",
parameters: [{ name: "@productId", value: PRODUCT_ID }],
})
.fetchAll();
return resources[0] ?? 0;
}

View File

@ -0,0 +1,191 @@
/**
* Invitation code REST endpoints.
*
* GET /invitations list all codes
* GET /invitations/:id get by ID
* POST /invitations create a new code
* PUT /invitations/:id update a code
* DELETE /invitations/:id delete a code
* POST /invitations/redeem redeem a code
* GET /invitations/count count codes
* POST /invitations/bulk bulk create codes from array
*/
import type { FastifyInstance } from "fastify";
import { PRODUCT_ID } from "../../lib/product-config.js";
import { BadRequestError, NotFoundError } from "../../lib/errors.js";
import { dispatchInvitationRedeemed } from "../../lib/webhooks.js";
import * as repo from "./repository.js";
import {
CreateInvitationSchema,
UpdateInvitationSchema,
RedeemInvitationSchema,
type InvitationCodeDoc,
} from "./types.js";
export async function invitationRoutes(app: FastifyInstance) {
// List
app.get("/invitations", async (req) => {
const { limit = "100", offset = "0" } = req.query as Record<string, string>;
const items = await repo.list(Number(limit), Number(offset));
return { invitations: items, count: items.length };
});
// Count
app.get("/invitations/count", async () => {
const total = await repo.count();
return { count: total };
});
// Get by ID
app.get("/invitations/:id", async (req) => {
const { id } = req.params as { id: string };
const doc = await repo.getById(id);
if (!doc) throw new NotFoundError("Invitation code not found");
return doc;
});
// Create
app.post("/invitations", async (req, reply) => {
const parsed = CreateInvitationSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const input = parsed.data;
const now = new Date().toISOString();
const doc: InvitationCodeDoc = {
id: `inv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
productId: PRODUCT_ID,
code: input.code.toUpperCase().replace(/[^A-Z0-9-]/g, ""),
description: input.description,
createdBy: input.createdBy,
grantPlan: input.grantPlan,
grantTrialDays: input.grantTrialDays,
bonusTokens: input.bonusTokens,
maxUses: input.maxUses,
currentUses: 0,
redeemedBy: [],
status: "active",
expiresAt: input.expiresAt,
createdAt: now,
updatedAt: now,
};
const created = await repo.create(doc);
reply.code(201);
return created;
});
// Update
app.put("/invitations/:id", async (req) => {
const { id } = req.params as { id: string };
const parsed = UpdateInvitationSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const updated = await repo.update(id, parsed.data);
if (!updated) throw new NotFoundError("Invitation code not found");
return updated;
});
// Delete
app.delete("/invitations/:id", async (req, reply) => {
const { id } = req.params as { id: string };
const ok = await repo.remove(id);
if (!ok) throw new NotFoundError("Invitation code not found");
reply.code(204);
return;
});
// Bulk create — accepts a JSON array of invitation inputs
app.post("/invitations/bulk", async (req, reply) => {
const body = req.body;
if (!Array.isArray(body)) {
throw new BadRequestError("Request body must be a JSON array of invitation objects");
}
if (body.length === 0) {
throw new BadRequestError("Array must not be empty");
}
if (body.length > 500) {
throw new BadRequestError("Maximum 500 invitations per bulk request");
}
const results: { created: InvitationCodeDoc[]; errors: { index: number; error: string }[] } = {
created: [],
errors: [],
};
for (let i = 0; i < body.length; i++) {
const parsed = CreateInvitationSchema.safeParse(body[i]);
if (!parsed.success) {
results.errors.push({
index: i,
error: parsed.error.issues.map((e) => e.message).join("; "),
});
continue;
}
const input = parsed.data;
const now = new Date().toISOString();
const doc: InvitationCodeDoc = {
id: `inv_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 8)}`,
productId: PRODUCT_ID,
code: input.code.toUpperCase().replace(/[^A-Z0-9-]/g, ""),
description: input.description,
createdBy: input.createdBy,
grantPlan: input.grantPlan,
grantTrialDays: input.grantTrialDays,
bonusTokens: input.bonusTokens,
maxUses: input.maxUses,
currentUses: 0,
redeemedBy: [],
status: "active",
expiresAt: input.expiresAt,
createdAt: now,
updatedAt: now,
};
try {
const created = await repo.create(doc);
results.created.push(created);
} catch (err) {
results.errors.push({
index: i,
error: err instanceof Error ? err.message : "Creation failed",
});
}
}
reply.code(results.errors.length === 0 ? 201 : 207);
return {
total: body.length,
created: results.created.length,
failed: results.errors.length,
invitations: results.created,
errors: results.errors,
};
});
// Redeem
app.post("/invitations/redeem", async (req) => {
const parsed = RedeemInvitationSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const redeemed = await repo.redeem(parsed.data.code, parsed.data.userId);
if (!redeemed) {
throw new BadRequestError(
"Code is invalid, expired, fully redeemed, or already used by this user",
);
}
// Fire-and-forget webhook callback
dispatchInvitationRedeemed({
code: redeemed.code,
userId: parsed.data.userId,
grantPlan: redeemed.grantPlan,
grantTrialDays: redeemed.grantTrialDays,
bonusTokens: redeemed.bonusTokens,
invitationId: redeemed.id,
});
return redeemed;
});
}

View File

@ -0,0 +1,53 @@
/**
* Invitation code types consolidated from admin + user dashboard repos.
*/
import { z } from "zod";
export interface InvitationCodeDoc {
id: string;
productId: string;
code: string;
description: string;
createdBy: string;
grantPlan: "pro" | "enterprise";
grantTrialDays: number;
bonusTokens: number;
maxUses: number;
currentUses: number;
redeemedBy: string[];
status: "active" | "expired" | "disabled";
expiresAt: string | null;
createdAt: string;
updatedAt: string;
}
export const CreateInvitationSchema = z.object({
code: z.string().min(3).max(50),
description: z.string().default(""),
createdBy: z.string().min(1),
grantPlan: z.enum(["pro", "enterprise"]),
grantTrialDays: z.number().int().min(0).default(0),
bonusTokens: z.number().int().min(0).default(0),
maxUses: z.number().int().min(1).default(1),
expiresAt: z.string().nullable().default(null),
});
export const UpdateInvitationSchema = z.object({
description: z.string().optional(),
grantPlan: z.enum(["pro", "enterprise"]).optional(),
grantTrialDays: z.number().int().min(0).optional(),
bonusTokens: z.number().int().min(0).optional(),
maxUses: z.number().int().min(1).optional(),
status: z.enum(["active", "expired", "disabled"]).optional(),
expiresAt: z.string().nullable().optional(),
});
export const RedeemInvitationSchema = z.object({
code: z.string().min(1),
userId: z.string().min(1),
});
export type CreateInvitationInput = z.infer<typeof CreateInvitationSchema>;
export type UpdateInvitationInput = z.infer<typeof UpdateInvitationSchema>;
export type RedeemInvitationInput = z.infer<typeof RedeemInvitationSchema>;

View File

@ -0,0 +1,80 @@
/**
* Unit tests for promos module types + validation.
*/
import { describe, it, expect } from "vitest";
import { CreatePromoSchema } from "./types.js";
describe("CreatePromoSchema", () => {
it("accepts valid percent-off promo", () => {
const result = CreatePromoSchema.safeParse({
code: "SUMMER20",
percentOff: 20,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.duration).toBe("once");
expect(result.data.currency).toBe("usd");
}
});
it("accepts valid amount-off promo", () => {
const result = CreatePromoSchema.safeParse({
code: "FLAT5",
amountOff: 500,
currency: "usd",
});
expect(result.success).toBe(true);
});
it("accepts repeating duration with months", () => {
const result = CreatePromoSchema.safeParse({
code: "REPEAT3",
percentOff: 10,
duration: "repeating",
durationInMonths: 3,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.duration).toBe("repeating");
expect(result.data.durationInMonths).toBe(3);
}
});
it("accepts promo with max redemptions and expiry", () => {
const result = CreatePromoSchema.safeParse({
code: "LIMITED",
percentOff: 50,
maxRedemptions: 100,
expiresAt: "2026-12-31T23:59:59Z",
createdBy: "admin@lysnr.ai",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.maxRedemptions).toBe(100);
expect(result.data.createdBy).toBe("admin@lysnr.ai");
}
});
it("rejects missing code", () => {
const result = CreatePromoSchema.safeParse({ percentOff: 20 });
expect(result.success).toBe(false);
});
it("rejects percent over 100", () => {
const result = CreatePromoSchema.safeParse({
code: "TOOMUCH",
percentOff: 101,
});
expect(result.success).toBe(false);
});
it("rejects invalid duration", () => {
const result = CreatePromoSchema.safeParse({
code: "BAD",
percentOff: 10,
duration: "weekly",
});
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,130 @@
/**
* Promo code REST endpoints wraps Stripe Coupon + Promotion Code APIs.
*
* GET /promos list promotion codes
* POST /promos create coupon + promotion code
* POST /promos/validate validate a promo code
* PUT /promos/:id/deactivate deactivate a promotion code
*/
import type { FastifyInstance } from "fastify";
import Stripe from "stripe";
import { PRODUCT_ID } from "../../lib/product-config.js";
import { BadRequestError } from "../../lib/errors.js";
import { CreatePromoSchema, type PromoCodeResponse } from "./types.js";
let _stripeInstance: Stripe | null = null;
function getStripe(): Stripe {
if (_stripeInstance) return _stripeInstance;
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY not configured");
_stripeInstance = new Stripe(key);
return _stripeInstance;
}
function mapPromo(p: Stripe.PromotionCode): PromoCodeResponse {
const coupon = p.coupon;
const isExpanded = coupon != null && typeof coupon !== "string";
return {
id: p.id,
code: p.code,
active: p.active,
couponId: isExpanded ? coupon.id : (coupon as string ?? null),
percentOff: isExpanded ? coupon.percent_off : null,
amountOff: isExpanded ? coupon.amount_off : null,
currency: isExpanded ? coupon.currency : null,
duration: isExpanded ? coupon.duration : null,
timesRedeemed: p.times_redeemed,
maxRedemptions: p.max_redemptions,
expiresAt: p.expires_at ? new Date(p.expires_at * 1000).toISOString() : null,
created: new Date(p.created * 1000).toISOString(),
metadata: (p.metadata ?? {}) as Record<string, string>,
};
}
export async function promoRoutes(app: FastifyInstance) {
// List
app.get("/promos", async (req) => {
const { active } = req.query as { active?: string };
const stripe = getStripe();
const promos = await stripe.promotionCodes.list({
limit: 100,
...(active !== undefined && { active: active === "true" }),
});
return { promos: promos.data.map(mapPromo) };
});
// Create
app.post("/promos", async (req, reply) => {
const parsed = CreatePromoSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; "));
}
const input = parsed.data;
if (!input.percentOff && !input.amountOff) {
throw new BadRequestError("Either percentOff or amountOff is required");
}
const stripe = getStripe();
// Create coupon first
const couponParams: Stripe.CouponCreateParams = {
duration: input.duration,
...(input.percentOff && { percent_off: input.percentOff }),
...(input.amountOff && { amount_off: input.amountOff, currency: input.currency }),
...(input.duration === "repeating" &&
input.durationInMonths && { duration_in_months: input.durationInMonths }),
metadata: { createdBy: input.createdBy, productId: PRODUCT_ID },
};
const coupon = await stripe.coupons.create(couponParams);
// Create promotion code
const promoParams: Stripe.PromotionCodeCreateParams = {
coupon: coupon.id,
code: input.code.toUpperCase().replace(/[^A-Z0-9-]/g, ""),
...(input.maxRedemptions && { max_redemptions: input.maxRedemptions }),
...(input.expiresAt && {
expires_at: Math.floor(new Date(input.expiresAt).getTime() / 1000),
}),
metadata: { createdBy: input.createdBy, productId: PRODUCT_ID },
};
const promo = await stripe.promotionCodes.create(promoParams);
reply.code(201);
return mapPromo(promo);
});
// Validate
app.post("/promos/validate", async (req) => {
const { code } = req.body as { code?: string };
if (!code) throw new BadRequestError("code is required");
const stripe = getStripe();
const promos = await stripe.promotionCodes.list({
code,
active: true,
limit: 1,
});
if (promos.data.length === 0) {
return { valid: false, promo: null };
}
return { valid: true, promo: mapPromo(promos.data[0]) };
});
// Deactivate
app.put("/promos/:id/deactivate", async (req) => {
const { id } = req.params as { id: string };
const stripe = getStripe();
try {
const promo = await stripe.promotionCodes.update(id, { active: false });
return mapPromo(promo);
} catch {
throw new BadRequestError("Failed to deactivate promotion code");
}
});
}

View File

@ -0,0 +1,39 @@
/**
* Promo code types wraps Stripe Coupon + Promotion Code APIs.
*/
import { z } from "zod";
export interface PromoCodeResponse {
id: string;
code: string;
active: boolean;
couponId: string | null;
percentOff: number | null;
amountOff: number | null;
currency: string | null;
duration: string | null;
timesRedeemed: number;
maxRedemptions: number | null;
expiresAt: string | null;
created: string;
metadata: Record<string, string>;
}
export const CreatePromoSchema = z.object({
code: z.string().min(1),
percentOff: z.number().min(1).max(100).optional(),
amountOff: z.number().int().min(1).optional(),
currency: z.string().default("usd"),
duration: z.enum(["once", "repeating", "forever"]).default("once"),
durationInMonths: z.number().int().min(1).optional(),
maxRedemptions: z.number().int().min(1).optional(),
expiresAt: z.string().optional(),
createdBy: z.string().default("system"),
});
export const DeactivatePromoSchema = z.object({
id: z.string().min(1),
});
export type CreatePromoInput = z.infer<typeof CreatePromoSchema>;

View File

@ -0,0 +1,92 @@
/**
* Unit tests for referrals module types + validation.
*/
import { describe, it, expect } from "vitest";
import { CreateReferralSchema, UpdateReferralStatusSchema } from "./types.js";
describe("CreateReferralSchema", () => {
it("accepts valid input with required fields", () => {
const result = CreateReferralSchema.safeParse({
referrerId: "user_abc",
referrerEmail: "alice@example.com",
referredEmail: "bob@example.com",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.referrerRewardTokens).toBe(1000);
expect(result.data.referredRewardTokens).toBe(500);
}
});
it("accepts custom reward amounts", () => {
const result = CreateReferralSchema.safeParse({
referrerId: "user_abc",
referrerEmail: "alice@example.com",
referredEmail: "bob@example.com",
referrerRewardTokens: 2000,
referredRewardTokens: 1000,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.referrerRewardTokens).toBe(2000);
expect(result.data.referredRewardTokens).toBe(1000);
}
});
it("rejects invalid referrer email", () => {
const result = CreateReferralSchema.safeParse({
referrerId: "user_abc",
referrerEmail: "not-an-email",
referredEmail: "bob@example.com",
});
expect(result.success).toBe(false);
});
it("rejects missing referrerId", () => {
const result = CreateReferralSchema.safeParse({
referrerEmail: "alice@example.com",
referredEmail: "bob@example.com",
});
expect(result.success).toBe(false);
});
it("rejects negative reward tokens", () => {
const result = CreateReferralSchema.safeParse({
referrerId: "user_abc",
referrerEmail: "alice@example.com",
referredEmail: "bob@example.com",
referrerRewardTokens: -100,
});
expect(result.success).toBe(false);
});
});
describe("UpdateReferralStatusSchema", () => {
it("accepts valid status update", () => {
const result = UpdateReferralStatusSchema.safeParse({
status: "signed_up",
referredUserId: "user_xyz",
});
expect(result.success).toBe(true);
});
it("accepts rewarded status with flags", () => {
const result = UpdateReferralStatusSchema.safeParse({
status: "rewarded",
referrerRewarded: true,
referredRewarded: true,
});
expect(result.success).toBe(true);
});
it("rejects invalid status value", () => {
const result = UpdateReferralStatusSchema.safeParse({ status: "cancelled" });
expect(result.success).toBe(false);
});
it("rejects missing status", () => {
const result = UpdateReferralStatusSchema.safeParse({});
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,124 @@
/**
* Referrals repository Cosmos DB CRUD operations.
* Consolidated from admin-dashboard-web + user-dashboard-web repos.
*/
import { getContainer } from "../../lib/cosmos.js";
import { PRODUCT_ID } from "../../lib/product-config.js";
import type { ReferralDoc } from "./types.js";
const CONTAINER = "referrals";
function container() {
return getContainer(CONTAINER);
}
export async function listAll(limit = 100, offset = 0): Promise<ReferralDoc[]> {
const { resources } = await container().items
.query<ReferralDoc>({
query:
"SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit",
parameters: [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@offset", value: offset },
{ name: "@limit", value: limit },
],
})
.fetchAll();
return resources;
}
export async function getByReferrer(referrerId: string): Promise<ReferralDoc[]> {
const { resources } = await container().items
.query<ReferralDoc>({
query:
"SELECT * FROM c WHERE c.productId = @productId AND c.referrerId = @rid ORDER BY c.createdAt DESC",
parameters: [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@rid", value: referrerId },
],
})
.fetchAll();
return resources;
}
export async function getByReferredEmail(email: string): Promise<ReferralDoc | null> {
const { resources } = await container().items
.query<ReferralDoc>({
query:
"SELECT * FROM c WHERE c.productId = @productId AND c.referredEmail = @email ORDER BY c.createdAt DESC",
parameters: [
{ name: "@productId", value: PRODUCT_ID },
{ name: "@email", value: email.toLowerCase() },
],
})
.fetchAll();
return resources[0] ?? null;
}
export async function getById(
id: string,
referrerId: string,
): Promise<ReferralDoc | null> {
try {
const { resource } = await container().item(id, referrerId).read<ReferralDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function create(doc: ReferralDoc): Promise<ReferralDoc> {
const { resource } = await container().items.create(doc);
return resource as ReferralDoc;
}
export async function update(
id: string,
referrerId: string,
updates: Partial<ReferralDoc>,
): Promise<ReferralDoc | null> {
try {
const { resource: existing } = await container()
.item(id, referrerId)
.read<ReferralDoc>();
if (!existing) return null;
const merged = { ...existing, ...updates };
const { resource } = await container().item(id, referrerId).replace(merged);
return resource as ReferralDoc;
} catch {
return null;
}
}
export async function countReferrals(): Promise<{
total: number;
completed: number;
rewarded: number;
}> {
const { resources: totalRes } = await container().items
.query<number>({
query: "SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId",
parameters: [{ name: "@productId", value: PRODUCT_ID }],
})
.fetchAll();
const { resources: completedRes } = await container().items
.query<number>({
query:
"SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status IN ('signed_up', 'subscribed', 'rewarded')",
parameters: [{ name: "@productId", value: PRODUCT_ID }],
})
.fetchAll();
const { resources: rewardedRes } = await container().items
.query<number>({
query:
"SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status = 'rewarded'",
parameters: [{ name: "@productId", value: PRODUCT_ID }],
})
.fetchAll();
return {
total: totalRes[0] ?? 0,
completed: completedRes[0] ?? 0,
rewarded: rewardedRes[0] ?? 0,
};
}

View File

@ -0,0 +1,124 @@
/**
* 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;
});
}

View File

@ -0,0 +1,39 @@
/**
* Referral types consolidated from admin + user dashboard repos.
*/
import { z } from "zod";
export interface ReferralDoc {
id: string;
productId: string;
referrerId: string;
referrerEmail: string;
referredUserId: string | null;
referredEmail: string;
status: "pending" | "signed_up" | "subscribed" | "rewarded";
referrerRewardTokens: number;
referredRewardTokens: number;
referrerRewarded: boolean;
referredRewarded: boolean;
createdAt: string;
completedAt: string | null;
}
export const CreateReferralSchema = z.object({
referrerId: z.string().min(1),
referrerEmail: z.string().email(),
referredEmail: z.string().email(),
referrerRewardTokens: z.number().int().min(0).default(1000),
referredRewardTokens: z.number().int().min(0).default(500),
});
export const UpdateReferralStatusSchema = z.object({
status: z.enum(["pending", "signed_up", "subscribed", "rewarded"]),
referredUserId: z.string().optional(),
referrerRewarded: z.boolean().optional(),
referredRewarded: z.boolean().optional(),
});
export type CreateReferralInput = z.infer<typeof CreateReferralSchema>;
export type UpdateReferralStatusInput = z.infer<typeof UpdateReferralStatusSchema>;

View File

@ -0,0 +1,78 @@
/**
* Growth Service Fastify server entry point.
*
* Modules: invitations, referrals, promo codes.
* Port: 4001 (configurable via PORT env var).
*/
import { randomUUID } from "node:crypto";
import Fastify from "fastify";
import cors from "@fastify/cors";
import swagger from "@fastify/swagger";
import metricsPlugin from "fastify-metrics";
import { ServiceError } from "./lib/errors.js";
import { invitationRoutes } from "./modules/invitations/routes.js";
import { referralRoutes } from "./modules/referrals/routes.js";
import { promoRoutes } from "./modules/promos/routes.js";
import { config } from "./lib/config.js";
const PORT = config.PORT;
const HOST = config.HOST;
const app = Fastify({ logger: true });
// CORS — restrict to specific origins in production via CORS_ORIGIN (comma-separated)
const corsOrigin = config.CORS_ORIGIN;
await app.register(cors, {
origin: corsOrigin ? corsOrigin.split(",").map((o) => o.trim()) : true,
});
// OpenAPI spec auto-generation (GET /api/docs/json)
await app.register(swagger, {
openapi: {
info: { title: "Growth Service", version: "0.1.0", description: "Invitations, referrals, promo codes" },
servers: [{ url: `http://localhost:${PORT}` }],
},
});
// Prometheus metrics
await app.register(metricsPlugin, { endpoint: "/metrics" });
// x-request-id: propagate incoming header or generate a new one
app.addHook("onRequest", async (req, reply) => {
const requestId = (req.headers["x-request-id"] as string) || randomUUID();
req.headers["x-request-id"] = requestId;
reply.header("x-request-id", requestId);
req.log = req.log.child({ requestId });
});
// Health check
app.get("/health", async (req) => ({
status: "ok",
service: "growth-service",
version: "0.1.0",
timestamp: new Date().toISOString(),
requestId: req.headers["x-request-id"],
}));
// Custom error handler for ServiceError
app.setErrorHandler((error, _req, reply) => {
if (error instanceof ServiceError) {
reply.code(error.statusCode).send({ error: error.message });
return;
}
app.log.error(error);
reply.code(500).send({ error: "Internal server error" });
});
// Register route modules
await app.register(invitationRoutes, { prefix: "/api" });
await app.register(referralRoutes, { prefix: "/api" });
await app.register(promoRoutes, { prefix: "/api" });
// Start
try {
await app.listen({ port: PORT, host: HOST });
app.log.info(`Growth Service listening on ${HOST}:${PORT}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}

View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}

View File

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
},
});