From e5501babc0b3f915b0b1ae1865617cab599bee77 Mon Sep 17 00:00:00 2001 From: Nonso Bethel Date: Mon, 27 Apr 2026 01:46:53 +0100 Subject: [PATCH 1/2] feat: implement referral program API (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ReferralCode and Referral models to Prisma schema - POST /referrals/code — generate unique referral code per user - POST /referrals/apply — attach referral code with self-referral and duplicate prevention - GET /referrals/stats — referral count, active referrals, earned bonuses - Bonus unlocked only after referred user completes first module - Unit tests covering all endpoints and edge cases --- prisma/schema.prisma | 31 ++++ src/controllers/referral.controller.ts | 205 ++++++++++++++++++++++++ src/routes/index.ts | 2 + src/routes/v1/referrals.routes.ts | 42 +++++ tests/referral.controller.test.ts | 207 +++++++++++++++++++++++++ 5 files changed, 487 insertions(+) create mode 100644 src/controllers/referral.controller.ts create mode 100644 src/routes/v1/referrals.routes.ts create mode 100644 tests/referral.controller.test.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index deb4e62..aeed37d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,6 +20,9 @@ model User { credentials Credential[] transactions Transaction[] syncEvents SyncEvent[] + referralCode ReferralCode? + referrals Referral[] @relation("Referrer") + referredBy Referral? @relation("Referree") @@map("users") } @@ -82,6 +85,34 @@ enum Role { INSTRUCTOR } +model ReferralCode { + id String @id @default(uuid()) + code String @unique + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + referrals Referral[] + + @@map("referral_codes") +} + +model Referral { + id String @id @default(uuid()) + referrerId String + referrer User @relation("Referrer", fields: [referrerId], references: [id], onDelete: Cascade) + referreeId String @unique + referree User @relation("Referree", fields: [referreeId], references: [id], onDelete: Cascade) + codeId String + code ReferralCode @relation(fields: [codeId], references: [id]) + bonusPaid Boolean @default(false) + bonusAmount Float? + bonusPaidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("referrals") +} + model SyncEvent { diff --git a/src/controllers/referral.controller.ts b/src/controllers/referral.controller.ts new file mode 100644 index 0000000..5a23dd4 --- /dev/null +++ b/src/controllers/referral.controller.ts @@ -0,0 +1,205 @@ +import { Request, Response } from 'express' +import { randomBytes } from 'crypto' +import prisma from '../config/database' +import { asyncHandler } from '../middleware/error.middleware' +import { BadRequestError, ConflictError, NotFoundError, UnauthorizedError } from '../utils/errors' + +const REFERRAL_BONUS_AMOUNT = 5.0 +const CODE_BYTES = 4 + +export class ReferralController { + /** + * @openapi + * /referrals/code: + * post: + * summary: Generate a unique referral code for the authenticated user + * tags: [Referrals] + * security: + * - bearerAuth: [] + * responses: + * 201: + * description: Referral code generated + * 409: + * description: User already has a referral code + * 401: + * description: Unauthorized + */ + generateCode = asyncHandler(async (req: Request, res: Response): Promise => { + const userId = (req as any).user?.id + if (!userId) throw new UnauthorizedError('User ID not found') + + const existing = await prisma.referralCode.findUnique({ where: { userId } }) + if (existing) { + res.status(200).json({ + success: true, + message: 'Referral code already exists', + data: { code: existing.code }, + }) + return + } + + const code = await this.generateUniqueCode() + const referralCode = await prisma.referralCode.create({ + data: { code, userId }, + }) + + res.status(201).json({ + success: true, + message: 'Referral code generated successfully', + data: { code: referralCode.code }, + }) + }) + + /** + * @openapi + * /referrals/apply: + * post: + * summary: Apply a referral code during signup or onboarding + * tags: [Referrals] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [code] + * properties: + * code: + * type: string + * responses: + * 201: + * description: Referral applied successfully + * 400: + * description: Invalid or self-referral code + * 409: + * description: User has already used a referral code + */ + applyCode = asyncHandler(async (req: Request, res: Response): Promise => { + const userId = (req as any).user?.id + if (!userId) throw new UnauthorizedError('User ID not found') + + const { code } = req.body + if (!code || typeof code !== 'string') { + throw new BadRequestError('Referral code is required') + } + + const referralCode = await prisma.referralCode.findUnique({ where: { code } }) + if (!referralCode) throw new NotFoundError('Referral code not found') + + if (referralCode.userId === userId) { + throw new BadRequestError('Self-referrals are not allowed') + } + + const alreadyReferred = await prisma.referral.findUnique({ where: { referreeId: userId } }) + if (alreadyReferred) throw new ConflictError('You have already used a referral code') + + const alreadyUsedThisCode = await prisma.referral.findFirst({ + where: { referrerId: referralCode.userId, referreeId: userId }, + }) + if (alreadyUsedThisCode) throw new ConflictError('This referral code has already been applied') + + const referral = await prisma.referral.create({ + data: { + referrerId: referralCode.userId, + referreeId: userId, + codeId: referralCode.id, + }, + }) + + res.status(201).json({ + success: true, + message: 'Referral code applied successfully', + data: { referralId: referral.id }, + }) + }) + + /** + * @openapi + * /referrals/stats: + * get: + * summary: Get referral stats for the authenticated user + * tags: [Referrals] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Referral stats retrieved successfully + * 401: + * description: Unauthorized + */ + getStats = asyncHandler(async (req: Request, res: Response): Promise => { + const userId = (req as any).user?.id + if (!userId) throw new UnauthorizedError('User ID not found') + + const referrals = await prisma.referral.findMany({ + where: { referrerId: userId }, + include: { + referree: { + select: { completions: { take: 1 } }, + }, + }, + }) + + const totalReferrals = referrals.length + const activeReferrals = referrals.filter((r) => r.referree.completions.length > 0).length + const paidBonuses = referrals.filter((r) => r.bonusPaid) + const earnedBonuses = paidBonuses.reduce((sum, r) => sum + (r.bonusAmount ?? 0), 0) + + res.status(200).json({ + success: true, + data: { + totalReferrals, + activeReferrals, + earnedBonuses, + pendingBonuses: (totalReferrals - paidBonuses.length) * REFERRAL_BONUS_AMOUNT, + }, + }) + }) + + /** + * Called internally when a referree completes their first module to unlock the referrer bonus. + */ + static async processReferralBonus(referreeId: string): Promise { + const referral = await prisma.referral.findUnique({ + where: { referreeId }, + }) + + if (!referral || referral.bonusPaid) return + + const completionCount = await prisma.completion.count({ where: { userId: referreeId } }) + if (completionCount < 1) return + + await prisma.referral.update({ + where: { id: referral.id }, + data: { + bonusPaid: true, + bonusAmount: REFERRAL_BONUS_AMOUNT, + bonusPaidAt: new Date(), + }, + }) + + await prisma.transaction.create({ + data: { + userId: referral.referrerId, + amount: REFERRAL_BONUS_AMOUNT, + type: 'referral_reward', + status: 'completed', + }, + }) + } + + private async generateUniqueCode(): Promise { + let code: string + let exists: boolean + + do { + code = randomBytes(CODE_BYTES).toString('hex').toUpperCase() + const found = await prisma.referralCode.findUnique({ where: { code } }) + exists = !!found + } while (exists) + + return code + } +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 70e5394..cb4e0bd 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -6,6 +6,7 @@ import credentialRoutes from './v1/credentials.routes' import rewardRoutes from './v1/rewards.routes' import userRoutes from './v1/users.routes' import syncRoutes from './v1/sync.routes' +import referralRoutes from './v1/referrals.routes' const router: Router = Router() @@ -20,5 +21,6 @@ router.use('/v1/credentials', credentialRoutes) router.use('/v1/rewards', rewardRoutes) router.use('/v1/employer', employerRoutes) router.use('/v1/sync', syncRoutes) +router.use('/v1/referrals', referralRoutes) export default router diff --git a/src/routes/v1/referrals.routes.ts b/src/routes/v1/referrals.routes.ts new file mode 100644 index 0000000..a3c8e8d --- /dev/null +++ b/src/routes/v1/referrals.routes.ts @@ -0,0 +1,42 @@ +import { Router } from 'express' +import { ReferralController } from '../../controllers/referral.controller' +import { authenticate } from '../../middleware/auth.middleware' + +const router = Router() +const referralController = new ReferralController() + +/** + * @route POST /api/v1/referrals/code + * @desc Generate a unique referral code for the authenticated user + * @access Private + */ +router.post( + '/code', + authenticate, + referralController.generateCode.bind(referralController), +) + +/** + * @route POST /api/v1/referrals/apply + * @desc Apply a referral code during signup or onboarding + * @body code - The referral code to apply (required) + * @access Private + */ +router.post( + '/apply', + authenticate, + referralController.applyCode.bind(referralController), +) + +/** + * @route GET /api/v1/referrals/stats + * @desc Get referral stats: count, active referrals, earned bonuses + * @access Private + */ +router.get( + '/stats', + authenticate, + referralController.getStats.bind(referralController), +) + +export default router diff --git a/tests/referral.controller.test.ts b/tests/referral.controller.test.ts new file mode 100644 index 0000000..942aa55 --- /dev/null +++ b/tests/referral.controller.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Request, Response } from 'express' +import { ReferralController } from '../src/controllers/referral.controller' + +vi.mock('../src/config/database', () => ({ + default: { + referralCode: { + findUnique: vi.fn(), + create: vi.fn(), + }, + referral: { + findUnique: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + completion: { + count: vi.fn(), + }, + transaction: { + create: vi.fn(), + }, + }, +})) + +import prisma from '../src/config/database' + +interface AuthRequest extends Request { + user?: { id: string; email: string; role: string } +} + +describe('ReferralController', () => { + let controller: ReferralController + let req: Partial + let res: Partial + let next: ReturnType + + beforeEach(() => { + controller = new ReferralController() + req = { user: { id: 'user-1', email: 'test@example.com', role: 'LEARNER' }, body: {} } + res = { json: vi.fn(), status: vi.fn().mockReturnThis() } + next = vi.fn() + vi.clearAllMocks() + }) + + describe('generateCode', () => { + it('returns existing code if user already has one', async () => { + vi.mocked(prisma.referralCode.findUnique).mockResolvedValue({ + id: 'rc-1', code: 'ABCD1234', userId: 'user-1', createdAt: new Date(), referrals: [], + } as any) + + await controller.generateCode(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ data: { code: 'ABCD1234' } }), + ) + }) + + it('creates and returns a new code if none exists', async () => { + vi.mocked(prisma.referralCode.findUnique).mockResolvedValueOnce(null).mockResolvedValueOnce(null) + vi.mocked(prisma.referralCode.create).mockResolvedValue({ + id: 'rc-2', code: 'NEWCODE1', userId: 'user-1', createdAt: new Date(), + } as any) + + await controller.generateCode(req as Request, res as Response, next) + + expect(prisma.referralCode.create).toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(201) + }) + + it('throws UnauthorizedError when user is missing', async () => { + req.user = undefined + await controller.generateCode(req as Request, res as Response, next) + expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'User ID not found' })) + }) + }) + + describe('applyCode', () => { + beforeEach(() => { + req.body = { code: 'REFCODE1' } + }) + + it('throws BadRequestError when code is missing', async () => { + req.body = {} + await controller.applyCode(req as Request, res as Response, next) + expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'Referral code is required' })) + }) + + it('throws NotFoundError for unknown code', async () => { + vi.mocked(prisma.referralCode.findUnique).mockResolvedValue(null) + await controller.applyCode(req as Request, res as Response, next) + expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'Referral code not found' })) + }) + + it('throws BadRequestError on self-referral', async () => { + vi.mocked(prisma.referralCode.findUnique).mockResolvedValue({ + id: 'rc-1', code: 'REFCODE1', userId: 'user-1', createdAt: new Date(), + } as any) + await controller.applyCode(req as Request, res as Response, next) + expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'Self-referrals are not allowed' })) + }) + + it('throws ConflictError if user already used a referral code', async () => { + vi.mocked(prisma.referralCode.findUnique).mockResolvedValue({ + id: 'rc-1', code: 'REFCODE1', userId: 'user-2', createdAt: new Date(), + } as any) + vi.mocked(prisma.referral.findUnique).mockResolvedValue({ id: 'ref-1' } as any) + await controller.applyCode(req as Request, res as Response, next) + expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'You have already used a referral code' })) + }) + + it('successfully applies a valid referral code', async () => { + vi.mocked(prisma.referralCode.findUnique).mockResolvedValue({ + id: 'rc-1', code: 'REFCODE1', userId: 'user-2', createdAt: new Date(), + } as any) + vi.mocked(prisma.referral.findUnique).mockResolvedValue(null) + vi.mocked(prisma.referral.findFirst).mockResolvedValue(null) + vi.mocked(prisma.referral.create).mockResolvedValue({ id: 'ref-new' } as any) + + await controller.applyCode(req as Request, res as Response, next) + + expect(prisma.referral.create).toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(201) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true })) + }) + }) + + describe('getStats', () => { + it('returns referral stats', async () => { + vi.mocked(prisma.referral.findMany).mockResolvedValue([ + { + id: 'ref-1', + bonusPaid: true, + bonusAmount: 5.0, + referree: { completions: [{ id: 'c-1' }] }, + }, + { + id: 'ref-2', + bonusPaid: false, + bonusAmount: null, + referree: { completions: [] }, + }, + ] as any) + + await controller.getStats(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + totalReferrals: 2, + activeReferrals: 1, + earnedBonuses: 5.0, + }), + }), + ) + }) + + it('throws UnauthorizedError when user is missing', async () => { + req.user = undefined + await controller.getStats(req as Request, res as Response, next) + expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'User ID not found' })) + }) + }) + + describe('processReferralBonus (static)', () => { + it('pays bonus when referree completes first module', async () => { + vi.mocked(prisma.referral.findUnique).mockResolvedValue({ + id: 'ref-1', referrerId: 'user-2', bonusPaid: false, + } as any) + vi.mocked(prisma.completion.count).mockResolvedValue(1) + vi.mocked(prisma.referral.update).mockResolvedValue({} as any) + vi.mocked(prisma.transaction.create).mockResolvedValue({} as any) + + await ReferralController.processReferralBonus('user-1') + + expect(prisma.referral.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ bonusPaid: true }) }), + ) + expect(prisma.transaction.create).toHaveBeenCalled() + }) + + it('skips bonus if already paid', async () => { + vi.mocked(prisma.referral.findUnique).mockResolvedValue({ + id: 'ref-1', referrerId: 'user-2', bonusPaid: true, + } as any) + + await ReferralController.processReferralBonus('user-1') + + expect(prisma.referral.update).not.toHaveBeenCalled() + }) + + it('skips bonus if no completions yet', async () => { + vi.mocked(prisma.referral.findUnique).mockResolvedValue({ + id: 'ref-1', referrerId: 'user-2', bonusPaid: false, + } as any) + vi.mocked(prisma.completion.count).mockResolvedValue(0) + + await ReferralController.processReferralBonus('user-1') + + expect(prisma.referral.update).not.toHaveBeenCalled() + }) + }) +}) From 2160d29d0e79e9136497d8a42f21bb0ef46f4fc8 Mon Sep 17 00:00:00 2001 From: Nonso Bethel Date: Mon, 27 Apr 2026 11:59:44 +0100 Subject: [PATCH 2/2] fix: add newline before return and use flushPromises in referral tests - Fix ESLint newline-before-return error in generateCode handler - Replace await on asyncHandler-wrapped calls with flushPromises helper to correctly drain the microtask queue before asserting on next() --- src/controllers/referral.controller.ts | 1 + tests/referral.controller.test.ts | 44 ++++++++++++++++++++------ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/controllers/referral.controller.ts b/src/controllers/referral.controller.ts index 5a23dd4..89e6791 100644 --- a/src/controllers/referral.controller.ts +++ b/src/controllers/referral.controller.ts @@ -35,6 +35,7 @@ export class ReferralController { message: 'Referral code already exists', data: { code: existing.code }, }) + return } diff --git a/tests/referral.controller.test.ts b/tests/referral.controller.test.ts index 942aa55..954bd39 100644 --- a/tests/referral.controller.test.ts +++ b/tests/referral.controller.test.ts @@ -26,6 +26,8 @@ vi.mock('../src/config/database', () => ({ import prisma from '../src/config/database' +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) + interface AuthRequest extends Request { user?: { id: string; email: string; role: string } } @@ -50,7 +52,8 @@ describe('ReferralController', () => { id: 'rc-1', code: 'ABCD1234', userId: 'user-1', createdAt: new Date(), referrals: [], } as any) - await controller.generateCode(req as Request, res as Response, next) + controller.generateCode(req as Request, res as Response, next) + await flushPromises() expect(res.status).toHaveBeenCalledWith(200) expect(res.json).toHaveBeenCalledWith( @@ -64,7 +67,8 @@ describe('ReferralController', () => { id: 'rc-2', code: 'NEWCODE1', userId: 'user-1', createdAt: new Date(), } as any) - await controller.generateCode(req as Request, res as Response, next) + controller.generateCode(req as Request, res as Response, next) + await flushPromises() expect(prisma.referralCode.create).toHaveBeenCalled() expect(res.status).toHaveBeenCalledWith(201) @@ -72,7 +76,10 @@ describe('ReferralController', () => { it('throws UnauthorizedError when user is missing', async () => { req.user = undefined - await controller.generateCode(req as Request, res as Response, next) + + controller.generateCode(req as Request, res as Response, next) + await flushPromises() + expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'User ID not found' })) }) }) @@ -84,13 +91,19 @@ describe('ReferralController', () => { it('throws BadRequestError when code is missing', async () => { req.body = {} - await controller.applyCode(req as Request, res as Response, next) + + controller.applyCode(req as Request, res as Response, next) + await flushPromises() + expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'Referral code is required' })) }) it('throws NotFoundError for unknown code', async () => { vi.mocked(prisma.referralCode.findUnique).mockResolvedValue(null) - await controller.applyCode(req as Request, res as Response, next) + + controller.applyCode(req as Request, res as Response, next) + await flushPromises() + expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'Referral code not found' })) }) @@ -98,7 +111,10 @@ describe('ReferralController', () => { vi.mocked(prisma.referralCode.findUnique).mockResolvedValue({ id: 'rc-1', code: 'REFCODE1', userId: 'user-1', createdAt: new Date(), } as any) - await controller.applyCode(req as Request, res as Response, next) + + controller.applyCode(req as Request, res as Response, next) + await flushPromises() + expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'Self-referrals are not allowed' })) }) @@ -107,7 +123,10 @@ describe('ReferralController', () => { id: 'rc-1', code: 'REFCODE1', userId: 'user-2', createdAt: new Date(), } as any) vi.mocked(prisma.referral.findUnique).mockResolvedValue({ id: 'ref-1' } as any) - await controller.applyCode(req as Request, res as Response, next) + + controller.applyCode(req as Request, res as Response, next) + await flushPromises() + expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'You have already used a referral code' })) }) @@ -119,7 +138,8 @@ describe('ReferralController', () => { vi.mocked(prisma.referral.findFirst).mockResolvedValue(null) vi.mocked(prisma.referral.create).mockResolvedValue({ id: 'ref-new' } as any) - await controller.applyCode(req as Request, res as Response, next) + controller.applyCode(req as Request, res as Response, next) + await flushPromises() expect(prisma.referral.create).toHaveBeenCalled() expect(res.status).toHaveBeenCalledWith(201) @@ -144,7 +164,8 @@ describe('ReferralController', () => { }, ] as any) - await controller.getStats(req as Request, res as Response, next) + controller.getStats(req as Request, res as Response, next) + await flushPromises() expect(res.status).toHaveBeenCalledWith(200) expect(res.json).toHaveBeenCalledWith( @@ -161,7 +182,10 @@ describe('ReferralController', () => { it('throws UnauthorizedError when user is missing', async () => { req.user = undefined - await controller.getStats(req as Request, res as Response, next) + + controller.getStats(req as Request, res as Response, next) + await flushPromises() + expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: 'User ID not found' })) }) })