From b94510aeb90a578d70d7b427902791ccf5def965 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 12 Feb 2026 11:39:11 -0800 Subject: [PATCH] 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 --- services/growth-service/.gitignore | 2 + services/growth-service/Dockerfile | 16 ++ services/growth-service/package.json | 30 +++ services/growth-service/src/lib/config.ts | 24 +++ services/growth-service/src/lib/cosmos.ts | 24 +++ .../growth-service/src/lib/errors.test.ts | 43 ++++ services/growth-service/src/lib/errors.ts | 34 ++++ .../growth-service/src/lib/product-config.ts | 10 + services/growth-service/src/lib/webhooks.ts | 69 +++++++ .../modules/invitations/invitations.test.ts | 124 ++++++++++++ .../src/modules/invitations/repository.ts | 108 ++++++++++ .../src/modules/invitations/routes.ts | 191 ++++++++++++++++++ .../src/modules/invitations/types.ts | 53 +++++ .../src/modules/promos/promos.test.ts | 80 ++++++++ .../src/modules/promos/routes.ts | 130 ++++++++++++ .../src/modules/promos/types.ts | 39 ++++ .../src/modules/referrals/referrals.test.ts | 92 +++++++++ .../src/modules/referrals/repository.ts | 124 ++++++++++++ .../src/modules/referrals/routes.ts | 124 ++++++++++++ .../src/modules/referrals/types.ts | 39 ++++ services/growth-service/src/server.ts | 78 +++++++ services/growth-service/tsconfig.json | 22 ++ services/growth-service/vitest.config.ts | 9 + 23 files changed, 1465 insertions(+) create mode 100644 services/growth-service/.gitignore create mode 100644 services/growth-service/Dockerfile create mode 100644 services/growth-service/package.json create mode 100644 services/growth-service/src/lib/config.ts create mode 100644 services/growth-service/src/lib/cosmos.ts create mode 100644 services/growth-service/src/lib/errors.test.ts create mode 100644 services/growth-service/src/lib/errors.ts create mode 100644 services/growth-service/src/lib/product-config.ts create mode 100644 services/growth-service/src/lib/webhooks.ts create mode 100644 services/growth-service/src/modules/invitations/invitations.test.ts create mode 100644 services/growth-service/src/modules/invitations/repository.ts create mode 100644 services/growth-service/src/modules/invitations/routes.ts create mode 100644 services/growth-service/src/modules/invitations/types.ts create mode 100644 services/growth-service/src/modules/promos/promos.test.ts create mode 100644 services/growth-service/src/modules/promos/routes.ts create mode 100644 services/growth-service/src/modules/promos/types.ts create mode 100644 services/growth-service/src/modules/referrals/referrals.test.ts create mode 100644 services/growth-service/src/modules/referrals/repository.ts create mode 100644 services/growth-service/src/modules/referrals/routes.ts create mode 100644 services/growth-service/src/modules/referrals/types.ts create mode 100644 services/growth-service/src/server.ts create mode 100644 services/growth-service/tsconfig.json create mode 100644 services/growth-service/vitest.config.ts diff --git a/services/growth-service/.gitignore b/services/growth-service/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/services/growth-service/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/services/growth-service/Dockerfile b/services/growth-service/Dockerfile new file mode 100644 index 00000000..85387e46 --- /dev/null +++ b/services/growth-service/Dockerfile @@ -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"] diff --git a/services/growth-service/package.json b/services/growth-service/package.json new file mode 100644 index 00000000..ee11be3d --- /dev/null +++ b/services/growth-service/package.json @@ -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" + } +} diff --git a/services/growth-service/src/lib/config.ts b/services/growth-service/src/lib/config.ts new file mode 100644 index 00000000..357135f9 --- /dev/null +++ b/services/growth-service/src/lib/config.ts @@ -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); diff --git a/services/growth-service/src/lib/cosmos.ts b/services/growth-service/src/lib/cosmos.ts new file mode 100644 index 00000000..9f08bdc1 --- /dev/null +++ b/services/growth-service/src/lib/cosmos.ts @@ -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); +} diff --git a/services/growth-service/src/lib/errors.test.ts b/services/growth-service/src/lib/errors.test.ts new file mode 100644 index 00000000..8dbf1a37 --- /dev/null +++ b/services/growth-service/src/lib/errors.test.ts @@ -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); + }); +}); diff --git a/services/growth-service/src/lib/errors.ts b/services/growth-service/src/lib/errors.ts new file mode 100644 index 00000000..f46b40c3 --- /dev/null +++ b/services/growth-service/src/lib/errors.ts @@ -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"; + } +} diff --git a/services/growth-service/src/lib/product-config.ts b/services/growth-service/src/lib/product-config.ts new file mode 100644 index 00000000..fa6520fd --- /dev/null +++ b/services/growth-service/src/lib/product-config.ts @@ -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"; diff --git a/services/growth-service/src/lib/webhooks.ts b/services/growth-service/src/lib/webhooks.ts new file mode 100644 index 00000000..f0e7e471 --- /dev/null +++ b/services/growth-service/src/lib/webhooks.ts @@ -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; +} + +/** + * 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, +): Promise { + 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): Promise { + return dispatchWebhook( + process.env.WEBHOOK_INVITATION_REDEEMED_URL, + "invitation.redeemed", + data, + ); +} + +/** Dispatch a referral.status_changed webhook. */ +export function dispatchReferralStatusChanged(data: Record): Promise { + return dispatchWebhook( + process.env.WEBHOOK_REFERRAL_STATUS_URL, + "referral.status_changed", + data, + ); +} diff --git a/services/growth-service/src/modules/invitations/invitations.test.ts b/services/growth-service/src/modules/invitations/invitations.test.ts new file mode 100644 index 00000000..3434324e --- /dev/null +++ b/services/growth-service/src/modules/invitations/invitations.test.ts @@ -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); + }); +}); diff --git a/services/growth-service/src/modules/invitations/repository.ts b/services/growth-service/src/modules/invitations/repository.ts new file mode 100644 index 00000000..301ed23f --- /dev/null +++ b/services/growth-service/src/modules/invitations/repository.ts @@ -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 { + const { resources } = await container().items + .query({ + 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 { + try { + const { resource } = await container().item(id, id).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function getByCode(code: string): Promise { + const { resources } = await container().items + .query({ + 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 { + const { resource } = await container().items.create(doc); + return resource as InvitationCodeDoc; +} + +export async function update( + id: string, + updates: Partial, +): Promise { + try { + const { resource: existing } = await container().item(id, id).read(); + 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 { + 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 { + try { + await container().item(id, id).delete(); + return true; + } catch { + return false; + } +} + +export async function count(): Promise { + const { resources } = await container().items + .query({ + query: "SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId", + parameters: [{ name: "@productId", value: PRODUCT_ID }], + }) + .fetchAll(); + return resources[0] ?? 0; +} diff --git a/services/growth-service/src/modules/invitations/routes.ts b/services/growth-service/src/modules/invitations/routes.ts new file mode 100644 index 00000000..f81c381b --- /dev/null +++ b/services/growth-service/src/modules/invitations/routes.ts @@ -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; + 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; + }); +} diff --git a/services/growth-service/src/modules/invitations/types.ts b/services/growth-service/src/modules/invitations/types.ts new file mode 100644 index 00000000..347bbc87 --- /dev/null +++ b/services/growth-service/src/modules/invitations/types.ts @@ -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; +export type UpdateInvitationInput = z.infer; +export type RedeemInvitationInput = z.infer; diff --git a/services/growth-service/src/modules/promos/promos.test.ts b/services/growth-service/src/modules/promos/promos.test.ts new file mode 100644 index 00000000..c43a25b0 --- /dev/null +++ b/services/growth-service/src/modules/promos/promos.test.ts @@ -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); + }); +}); diff --git a/services/growth-service/src/modules/promos/routes.ts b/services/growth-service/src/modules/promos/routes.ts new file mode 100644 index 00000000..86f0e0d4 --- /dev/null +++ b/services/growth-service/src/modules/promos/routes.ts @@ -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, + }; +} + +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"); + } + }); +} diff --git a/services/growth-service/src/modules/promos/types.ts b/services/growth-service/src/modules/promos/types.ts new file mode 100644 index 00000000..fed68de8 --- /dev/null +++ b/services/growth-service/src/modules/promos/types.ts @@ -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; +} + +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; diff --git a/services/growth-service/src/modules/referrals/referrals.test.ts b/services/growth-service/src/modules/referrals/referrals.test.ts new file mode 100644 index 00000000..b5e6ad46 --- /dev/null +++ b/services/growth-service/src/modules/referrals/referrals.test.ts @@ -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); + }); +}); diff --git a/services/growth-service/src/modules/referrals/repository.ts b/services/growth-service/src/modules/referrals/repository.ts new file mode 100644 index 00000000..620b8820 --- /dev/null +++ b/services/growth-service/src/modules/referrals/repository.ts @@ -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 { + const { resources } = await container().items + .query({ + 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 { + const { resources } = await container().items + .query({ + 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 { + const { resources } = await container().items + .query({ + 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 { + try { + const { resource } = await container().item(id, referrerId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function create(doc: ReferralDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as ReferralDoc; +} + +export async function update( + id: string, + referrerId: string, + updates: Partial, +): Promise { + try { + const { resource: existing } = await container() + .item(id, referrerId) + .read(); + 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({ + 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({ + 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({ + 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, + }; +} diff --git a/services/growth-service/src/modules/referrals/routes.ts b/services/growth-service/src/modules/referrals/routes.ts new file mode 100644 index 00000000..8d4075a9 --- /dev/null +++ b/services/growth-service/src/modules/referrals/routes.ts @@ -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; + const items = await repo.listAll(Number(limit), Number(offset)); + return { referrals: items, count: items.length }; + }); + + // Stats + app.get("/referrals/stats", async () => { + return repo.countReferrals(); + }); + + // By referrer + app.get("/referrals/by-referrer/:referrerId", async (req) => { + const { referrerId } = req.params as { referrerId: string }; + const items = await repo.getByReferrer(referrerId); + return { referrals: items, count: items.length }; + }); + + // By referred email + app.get("/referrals/by-email/:email", async (req) => { + const { email } = req.params as { email: string }; + const doc = await repo.getByReferredEmail(email); + if (!doc) throw new NotFoundError("Referral not found"); + return doc; + }); + + // Create + app.post("/referrals", async (req, reply) => { + const parsed = CreateReferralSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + const input = parsed.data; + + // Check if referral already exists for this email + const existing = await repo.getByReferredEmail(input.referredEmail); + if (existing) { + throw new BadRequestError("A referral already exists for this email"); + } + + const now = new Date().toISOString(); + const doc: ReferralDoc = { + id: `ref_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + productId: PRODUCT_ID, + referrerId: input.referrerId, + referrerEmail: input.referrerEmail, + referredUserId: null, + referredEmail: input.referredEmail.toLowerCase(), + status: "pending", + referrerRewardTokens: input.referrerRewardTokens, + referredRewardTokens: input.referredRewardTokens, + referrerRewarded: false, + referredRewarded: false, + createdAt: now, + completedAt: null, + }; + const created = await repo.create(doc); + reply.code(201); + return created; + }); + + // Update status + app.put("/referrals/:id", async (req) => { + const { id } = req.params as { id: string }; + const { referrerId } = req.query as { referrerId?: string }; + if (!referrerId) { + throw new BadRequestError("referrerId query parameter is required"); + } + + const parsed = UpdateReferralStatusSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((i) => i.message).join("; ")); + } + + const updates: Partial = { ...parsed.data }; + if ( + parsed.data.status === "rewarded" || + parsed.data.status === "subscribed" || + parsed.data.status === "signed_up" + ) { + updates.completedAt = updates.completedAt ?? new Date().toISOString(); + } + + const updated = await repo.update(id, referrerId, updates); + if (!updated) throw new NotFoundError("Referral not found"); + + // Fire-and-forget webhook callback on status transition + dispatchReferralStatusChanged({ + referralId: updated.id, + referrerId: updated.referrerId, + referredEmail: updated.referredEmail, + previousStatus: parsed.data.status, // requested status + newStatus: updated.status, + referrerRewarded: updated.referrerRewarded, + referredRewarded: updated.referredRewarded, + }); + + return updated; + }); +} diff --git a/services/growth-service/src/modules/referrals/types.ts b/services/growth-service/src/modules/referrals/types.ts new file mode 100644 index 00000000..4edc72b5 --- /dev/null +++ b/services/growth-service/src/modules/referrals/types.ts @@ -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; +export type UpdateReferralStatusInput = z.infer; diff --git a/services/growth-service/src/server.ts b/services/growth-service/src/server.ts new file mode 100644 index 00000000..9afaf15e --- /dev/null +++ b/services/growth-service/src/server.ts @@ -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); +} diff --git a/services/growth-service/tsconfig.json b/services/growth-service/tsconfig.json new file mode 100644 index 00000000..b40fd704 --- /dev/null +++ b/services/growth-service/tsconfig.json @@ -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"] +} diff --git a/services/growth-service/vitest.config.ts b/services/growth-service/vitest.config.ts new file mode 100644 index 00000000..0f78dc01 --- /dev/null +++ b/services/growth-service/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + }, +});