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