diff --git a/README.md b/README.md index 64b91ec..889cfcd 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Orivex Backend is one of three repos. Together they form the full stack. - **📚 Modules & Progress** — Catalogue, completion submission, offline sync, idempotent ingestion. - **🎁 Rewards Engine** — Stellar-based payouts with reconcilable memos and dispute handling. - **🎓 Verifiable Credentials** — Mint, fetch, and publicly verify credentials by on-chain ID. -- **🤝 Referrals** — Multi-tier tracking, capped rewards, anti-abuse heuristics. +- **🤝 Referrals** — Multi-tier tracking, capped rewards, anti-abuse heuristics, with the referrer bonus credited automatically on the referee's first module completion. ### ⚙️ Operations & Integrations diff --git a/src/controllers/module.controller.ts b/src/controllers/module.controller.ts index 4c61788..e48f197 100644 --- a/src/controllers/module.controller.ts +++ b/src/controllers/module.controller.ts @@ -6,6 +6,7 @@ import { z } from 'zod' import { prisma } from '../config/database' import { NotificationService } from '../services/notification.service' import { WebhookService } from '../services/webhook.service' +import { ReferralController } from './referral.controller' const notificationService = new NotificationService() const webhookService = new WebhookService() @@ -467,6 +468,15 @@ export const completeModule = async (req: Request, res: Response) => { }) } + // Credit the referrer's bonus on the referee's first completion. This is idempotent + // (guarded by the bonusPaid flag and a conditional updateMany) so a second module + // completion never double-pays. A failure here must not fail the user's completion. + try { + await ReferralController.processReferralBonus(req.user.id) + } catch (error) { + console.error('Error processing referral bonus:', error) + } + // Fire push notification for quiz pass/fail (non-blocking) notificationService.queueNotification( req.user.id, diff --git a/src/controllers/referral.controller.ts b/src/controllers/referral.controller.ts index c21083f..b313821 100644 --- a/src/controllers/referral.controller.ts +++ b/src/controllers/referral.controller.ts @@ -56,6 +56,7 @@ export class ReferralController { * /referrals/apply: * post: * summary: Apply a referral code during signup or onboarding + * description: The referrer's bonus is credited automatically once the referred user completes their first module. * tags: [Referrals] * security: * - bearerAuth: [] @@ -168,6 +169,11 @@ export class ReferralController { /** * Called internally when a referree completes their first module to unlock the referrer bonus. + * + * The bonusPaid transition is performed with a conditional updateMany so that two + * concurrent first-completion requests cannot both credit the bonus: only one call + * can flip bonusPaid from false to true, and the loser observes a zero count and + * returns without writing a reward transaction. */ static async processReferralBonus(referreeId: string): Promise { const referral = await prisma.referral.findUnique({ @@ -179,8 +185,8 @@ export class ReferralController { const completionCount = await prisma.completion.count({ where: { userId: referreeId } }) if (completionCount < 1) return - await prisma.referral.update({ - where: { id: referral.id }, + const result = await prisma.referral.updateMany({ + where: { id: referral.id, bonusPaid: false }, data: { bonusPaid: true, bonusAmount: REFERRAL_BONUS_AMOUNT, @@ -188,6 +194,9 @@ export class ReferralController { }, }) + // Another request already credited this bonus — do not double-pay. + if (result.count === 0) return + await prisma.transaction.create({ data: { userId: referral.referrerId, diff --git a/src/controllers/sync.controller.ts b/src/controllers/sync.controller.ts index 5c96374..02db503 100644 --- a/src/controllers/sync.controller.ts +++ b/src/controllers/sync.controller.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express' import prisma from '../config/database' import { asyncHandler } from '../middleware/error.middleware' import { BadRequestError, UnauthorizedError } from '../utils/errors' +import { ReferralController } from './referral.controller' interface ProgressEvent { idempotencyKey: string @@ -226,6 +227,14 @@ export class SyncController { await prisma.completion.create({ data: { userId, moduleId, score }, }) + + // A first completion arriving via offline sync must also unlock the + // referrer's bonus. The call is idempotent, so later completions are no-ops. + try { + await ReferralController.processReferralBonus(userId) + } catch (error) { + console.error('Error processing referral bonus:', error) + } } await prisma.syncEvent.create({ diff --git a/tests/referral.controller.test.ts b/tests/referral.controller.test.ts index 954bd39..cc84d7e 100644 --- a/tests/referral.controller.test.ts +++ b/tests/referral.controller.test.ts @@ -13,7 +13,7 @@ vi.mock('../src/config/database', () => ({ findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn(), - update: vi.fn(), + updateMany: vi.fn(), }, completion: { count: vi.fn(), @@ -196,15 +196,27 @@ describe('ReferralController', () => { 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.referral.updateMany).mockResolvedValue({ count: 1 }) 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.referral.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 'ref-1', bonusPaid: false }), + data: expect.objectContaining({ bonusPaid: true, bonusAmount: 5.0 }), + }), + ) + expect(prisma.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: 'user-2', + amount: 5.0, + type: 'referral_reward', + status: 'completed', + }), + }), ) - expect(prisma.transaction.create).toHaveBeenCalled() }) it('skips bonus if already paid', async () => { @@ -214,7 +226,8 @@ describe('ReferralController', () => { await ReferralController.processReferralBonus('user-1') - expect(prisma.referral.update).not.toHaveBeenCalled() + expect(prisma.referral.updateMany).not.toHaveBeenCalled() + expect(prisma.transaction.create).not.toHaveBeenCalled() }) it('skips bonus if no completions yet', async () => { @@ -225,7 +238,24 @@ describe('ReferralController', () => { await ReferralController.processReferralBonus('user-1') - expect(prisma.referral.update).not.toHaveBeenCalled() + expect(prisma.referral.updateMany).not.toHaveBeenCalled() + expect(prisma.transaction.create).not.toHaveBeenCalled() + }) + + it('does not double-pay when a concurrent request already credited the bonus', async () => { + vi.mocked(prisma.referral.findUnique).mockResolvedValue({ + id: 'ref-1', referrerId: 'user-2', bonusPaid: false, + } as any) + vi.mocked(prisma.completion.count).mockResolvedValue(1) + // The conditional update affects zero rows because the bonus was already marked paid. + vi.mocked(prisma.referral.updateMany).mockResolvedValue({ count: 0 }) + + await ReferralController.processReferralBonus('user-1') + + expect(prisma.referral.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ id: 'ref-1', bonusPaid: false }) }), + ) + expect(prisma.transaction.create).not.toHaveBeenCalled() }) }) }) diff --git a/tests/sync.controller.test.ts b/tests/sync.controller.test.ts index 67099c8..8a0e6ff 100644 --- a/tests/sync.controller.test.ts +++ b/tests/sync.controller.test.ts @@ -13,10 +13,18 @@ vi.mock('../src/config/database', () => ({ findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), + count: vi.fn(), }, module: { findUnique: vi.fn(), }, + referral: { + findUnique: vi.fn(), + updateMany: vi.fn(), + }, + transaction: { + create: vi.fn(), + }, }, })) @@ -245,6 +253,35 @@ describe('SyncController', () => { expect(call.data.results[0].status).toBe('applied') }) + it('credits the referrer bonus when a first completion is synced offline', async () => { + req.body = { events: [makeCompletionEvent()] } + vi.mocked(prisma.syncEvent.findUnique).mockResolvedValue(null) + vi.mocked(prisma.module.findUnique).mockResolvedValue({ id: 'module-1' } as any) + vi.mocked(prisma.completion.findUnique).mockResolvedValue(null) + vi.mocked(prisma.completion.create).mockResolvedValue({} as any) + vi.mocked(prisma.syncEvent.create).mockResolvedValue({} as any) + 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.updateMany).mockResolvedValue({ count: 1 }) + vi.mocked(prisma.transaction.create).mockResolvedValue({} as any) + + controller.syncCompletions(req as Request, res as Response, next) + await flushPromises() + + expect(prisma.referral.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 'ref-1', bonusPaid: false }), + }), + ) + expect(prisma.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ type: 'referral_reward' }), + }), + ) + }) + it('rejects event with invalid score', async () => { req.body = { events: [makeCompletionEvent({ score: -5 })] } vi.mocked(prisma.syncEvent.findUnique).mockResolvedValue(null) diff --git a/tests/unit/module.controller.test.ts b/tests/unit/module.controller.test.ts index 8734a07..13e24fb 100644 --- a/tests/unit/module.controller.test.ts +++ b/tests/unit/module.controller.test.ts @@ -1,8 +1,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Request, Response } from 'express' +import type { Request, Response } from 'express' import { completeModule } from '../../src/controllers/module.controller' import { prisma } from '../../src/config/database' +// Shared Prisma mock: module.controller imports the named `{ prisma }` export while +// referral.controller imports the default export, and both must observe the same calls. +const prismaMock = vi.hoisted(() => ({ + module: { + findUnique: vi.fn(), + }, + completion: { + findUnique: vi.fn(), + update: vi.fn(), + count: vi.fn(), + }, + quizQuestion: { + findMany: vi.fn(), + }, + transaction: { + create: vi.fn(), + }, + referral: { + findUnique: vi.fn(), + updateMany: vi.fn(), + }, +})) + const { queueEventMock } = vi.hoisted(() => ({ queueEventMock: vi.fn().mockResolvedValue(undefined), })) @@ -12,21 +35,8 @@ const { queueNotificationMock } = vi.hoisted(() => ({ })) vi.mock('../../src/config/database', () => ({ - prisma: { - module: { - findUnique: vi.fn(), - }, - quizQuestion: { - findMany: vi.fn(), - }, - completion: { - findUnique: vi.fn(), - update: vi.fn(), - }, - transaction: { - create: vi.fn(), - }, - }, + prisma: prismaMock, + default: prismaMock, })) vi.mock('../../src/services/notification.service', () => ({ @@ -49,34 +59,40 @@ function createResponse() { return response as Response } +function makeRequest(userId: string, moduleId: string) { + return { + user: { id: userId }, + params: { id: moduleId }, + body: { quizAnswers: [{ questionId: 'q1', answer: 'a' }] }, + } as unknown as Request +} + describe('ModuleController.completeModule', () => { beforeEach(() => { vi.clearAllMocks() }) it('emits module.completed webhook event after a passing quiz', async () => { - ;(prisma.module.findUnique as any).mockResolvedValue({ + vi.mocked(prisma.module.findUnique).mockResolvedValue({ id: 'mod-1', title: 'Stellar Fundamentals', reward: 10, - }) - ;(prisma.completion.findUnique as any).mockResolvedValue({ + } as any) + vi.mocked(prisma.completion.findUnique).mockResolvedValue({ userId: 'user-1', moduleId: 'mod-1', score: -1, - }) + } as any) + vi.mocked(prisma.quizQuestion.findMany).mockResolvedValue([ + { id: 'q1', answerKey: 'a' }, + ] as any) const completedAt = new Date('2026-01-01T00:00:00Z') - ;(prisma.completion.update as any).mockResolvedValue({ completedAt }) - ;(prisma.transaction.create as any).mockResolvedValue({ id: 'txn-1' }) - ;(prisma.quizQuestion.findMany as any).mockResolvedValue([ - { id: 'q1', moduleId: 'mod-1', prompt: 'Q1', options: '[]', answerKey: 'a', position: 0 }, - ]) - - const req = { - user: { id: 'user-1' }, - params: { id: 'mod-1' }, - body: { quizAnswers: [{ questionId: 'q1', answer: 'a' }] }, - } as unknown as Request + vi.mocked(prisma.completion.update).mockResolvedValue({ completedAt } as any) + vi.mocked(prisma.transaction.create).mockResolvedValue({ id: 'txn-1' } as any) + // No referral in play for this user, so the bonus hook is a no-op. + vi.mocked(prisma.referral.findUnique).mockResolvedValue(null) + + const req = makeRequest('user-1', 'mod-1') const res = createResponse() await completeModule(req, res) @@ -94,4 +110,124 @@ describe('ModuleController.completeModule', () => { }), ) }) + + it('credits the referrer bonus on the first completion', async () => { + vi.mocked(prisma.module.findUnique).mockResolvedValue({ + id: 'mod-1', + title: 'Intro', + reward: 10, + } as any) + vi.mocked(prisma.completion.findUnique).mockResolvedValue({ + userId: 'user-1', + moduleId: 'mod-1', + score: -1, + } as any) + vi.mocked(prisma.quizQuestion.findMany).mockResolvedValue([ + { id: 'q1', answerKey: 'a' }, + ] as any) + vi.mocked(prisma.completion.update).mockResolvedValue({} as any) + vi.mocked(prisma.transaction.create).mockResolvedValue({ id: 'txn-1' } as any) + + 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.updateMany).mockResolvedValue({ count: 1 }) + + const req = makeRequest('user-1', 'mod-1') + const res = createResponse() + + await completeModule(req, res) + + expect(prisma.referral.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 'ref-1', bonusPaid: false }), + data: expect.objectContaining({ bonusPaid: true }), + }), + ) + expect(prisma.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: 'user-2', + type: 'referral_reward', + status: 'completed', + }), + }), + ) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Module completed successfully' }), + ) + }) + + it('does not credit the bonus a second time for a later completion', async () => { + vi.mocked(prisma.module.findUnique).mockResolvedValue({ + id: 'mod-2', + title: 'Next', + reward: 10, + } as any) + vi.mocked(prisma.completion.findUnique).mockResolvedValue({ + userId: 'user-1', + moduleId: 'mod-2', + score: -1, + } as any) + vi.mocked(prisma.quizQuestion.findMany).mockResolvedValue([ + { id: 'q1', answerKey: 'a' }, + ] as any) + vi.mocked(prisma.completion.update).mockResolvedValue({} as any) + vi.mocked(prisma.transaction.create).mockResolvedValue({ id: 'txn-2' } as any) + + // The referrer's bonus was already paid on an earlier completion. + vi.mocked(prisma.referral.findUnique).mockResolvedValue({ + id: 'ref-1', + referrerId: 'user-2', + bonusPaid: true, + } as any) + vi.mocked(prisma.completion.count).mockResolvedValue(2) + + const req = makeRequest('user-1', 'mod-2') + const res = createResponse() + + await completeModule(req, res) + + expect(prisma.referral.updateMany).not.toHaveBeenCalled() + // Only the learner's own reward transaction is created — no referral_reward. + expect(prisma.transaction.create).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ type: 'referral_reward' }), + }), + ) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Module completed successfully' }), + ) + }) + + it('still completes the module when bonus processing throws', async () => { + vi.mocked(prisma.module.findUnique).mockResolvedValue({ + id: 'mod-1', + title: 'Intro', + reward: 10, + } as any) + vi.mocked(prisma.completion.findUnique).mockResolvedValue({ + userId: 'user-1', + moduleId: 'mod-1', + score: -1, + } as any) + vi.mocked(prisma.quizQuestion.findMany).mockResolvedValue([ + { id: 'q1', answerKey: 'a' }, + ] as any) + vi.mocked(prisma.completion.update).mockResolvedValue({} as any) + vi.mocked(prisma.transaction.create).mockResolvedValue({ id: 'txn-1' } as any) + vi.mocked(prisma.referral.findUnique).mockRejectedValue(new Error('db down')) + + const req = makeRequest('user-1', 'mod-1') + const res = createResponse() + + await completeModule(req, res) + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Module completed successfully' }), + ) + }) })