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:
parent
fc5f2bf296
commit
b94510aeb9
2
services/growth-service/.gitignore
vendored
Normal file
2
services/growth-service/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
16
services/growth-service/Dockerfile
Normal file
16
services/growth-service/Dockerfile
Normal 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"]
|
||||||
30
services/growth-service/package.json
Normal file
30
services/growth-service/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
services/growth-service/src/lib/config.ts
Normal file
24
services/growth-service/src/lib/config.ts
Normal 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);
|
||||||
24
services/growth-service/src/lib/cosmos.ts
Normal file
24
services/growth-service/src/lib/cosmos.ts
Normal 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);
|
||||||
|
}
|
||||||
43
services/growth-service/src/lib/errors.test.ts
Normal file
43
services/growth-service/src/lib/errors.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
services/growth-service/src/lib/errors.ts
Normal file
34
services/growth-service/src/lib/errors.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
10
services/growth-service/src/lib/product-config.ts
Normal file
10
services/growth-service/src/lib/product-config.ts
Normal 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";
|
||||||
69
services/growth-service/src/lib/webhooks.ts
Normal file
69
services/growth-service/src/lib/webhooks.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
services/growth-service/src/modules/invitations/repository.ts
Normal file
108
services/growth-service/src/modules/invitations/repository.ts
Normal 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;
|
||||||
|
}
|
||||||
191
services/growth-service/src/modules/invitations/routes.ts
Normal file
191
services/growth-service/src/modules/invitations/routes.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
53
services/growth-service/src/modules/invitations/types.ts
Normal file
53
services/growth-service/src/modules/invitations/types.ts
Normal 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>;
|
||||||
80
services/growth-service/src/modules/promos/promos.test.ts
Normal file
80
services/growth-service/src/modules/promos/promos.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
130
services/growth-service/src/modules/promos/routes.ts
Normal file
130
services/growth-service/src/modules/promos/routes.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
39
services/growth-service/src/modules/promos/types.ts
Normal file
39
services/growth-service/src/modules/promos/types.ts
Normal 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>;
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
services/growth-service/src/modules/referrals/repository.ts
Normal file
124
services/growth-service/src/modules/referrals/repository.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
124
services/growth-service/src/modules/referrals/routes.ts
Normal file
124
services/growth-service/src/modules/referrals/routes.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
39
services/growth-service/src/modules/referrals/types.ts
Normal file
39
services/growth-service/src/modules/referrals/types.ts
Normal 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>;
|
||||||
78
services/growth-service/src/server.ts
Normal file
78
services/growth-service/src/server.ts
Normal 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);
|
||||||
|
}
|
||||||
22
services/growth-service/tsconfig.json
Normal file
22
services/growth-service/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
9
services/growth-service/vitest.config.ts
Normal file
9
services/growth-service/vitest.config.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user