From 86cae7a83e44e55ba026344067e89dbd881f4231 Mon Sep 17 00:00:00 2001 From: Icahbod Date: Mon, 29 Jun 2026 11:10:43 +0000 Subject: [PATCH] feat(referrals): backend referral system with codes, tracking, rewards --- backend/src/auth/auth.module.ts | 2 + backend/src/auth/auth.service.ts | 17 ++ backend/src/auth/dto/create-user.dto.ts | 11 + backend/src/payments/payments.module.ts | 2 + .../providers/handle-webhook.provider.ts | 14 ++ .../src/referrals/entities/referral.entity.ts | 60 ++++++ backend/src/referrals/referrals.controller.ts | 25 ++- backend/src/referrals/referrals.module.ts | 4 +- .../src/referrals/referrals.service.spec.ts | 192 ++++++++++++++++++ backend/src/referrals/referrals.service.ts | 111 ++++++++-- backend/src/users/entities/user.entity.ts | 3 + 11 files changed, 416 insertions(+), 25 deletions(-) create mode 100644 backend/src/referrals/entities/referral.entity.ts create mode 100644 backend/src/referrals/referrals.service.spec.ts diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 2781ff4..4eec37a 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -17,6 +17,7 @@ import { RefreshToken } from './entities/refreshToken.entity'; import { SetupTotpProvider } from './providers/setup-totp.provider'; import { VerifyTotpProvider } from './providers/verify-totp.provider'; import { ManageTotpProvider } from './providers/manage-totp.provider'; +import { ReferralsModule } from '../referrals/referrals.module'; @Module({ imports: [ @@ -33,6 +34,7 @@ import { ManageTotpProvider } from './providers/manage-totp.provider'; }), }), PassportModule, + ReferralsModule, ], controllers: [AuthController], providers: [ diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 72a1174..5d83690 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -3,6 +3,7 @@ import { ConflictException, Injectable, InternalServerErrorException, + Logger, NotFoundException, UnauthorizedException, } from '@nestjs/common'; @@ -28,9 +29,12 @@ import { Setup2faDto } from './dto/setup-2fa.dto'; import { VerifyTotpDto } from './dto/verify-totp.dto'; import { UseBackupCodeDto } from './dto/use-backup-code.dto'; import { Disable2faDto } from './dto/disable-2fa.dto'; +import { ReferralsService } from '../referrals/referrals.service'; @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + constructor( @InjectRepository(User) private readonly userRepository: Repository, @@ -40,6 +44,7 @@ export class AuthService { private readonly setupTotpProvider: SetupTotpProvider, private readonly verifyTotpProvider: VerifyTotpProvider, private readonly manageTotpProvider: ManageTotpProvider, + private readonly referralsService: ReferralsService, ) {} async createUser(createUserDto: CreateUserDto) { @@ -74,6 +79,18 @@ export class AuthService { }); await this.userRepository.save(newUser); + // Track referral relationship if a referral code was provided at signup. + // Failures here must not break registration — log and continue. + if (createUserDto.referralCode) { + this.referralsService + .createReferral(createUserDto.referralCode, newUser.id) + .catch((err: Error) => { + this.logger.warn( + `Failed to record referral ${createUserDto.referralCode}: ${err.message}`, + ); + }); + } + await this.emailService.sendVerificationEmail( newUser.email, verificationCode, diff --git a/backend/src/auth/dto/create-user.dto.ts b/backend/src/auth/dto/create-user.dto.ts index 176d24b..3896e02 100644 --- a/backend/src/auth/dto/create-user.dto.ts +++ b/backend/src/auth/dto/create-user.dto.ts @@ -3,6 +3,7 @@ import { MinLength, IsNotEmpty, IsEmail, + IsOptional, MaxLength, } from 'class-validator'; @@ -23,4 +24,14 @@ export class CreateUserDto { @IsNotEmpty({ message: 'password can not be empty' }) @MinLength(6, { message: 'password must be at least 6 character long' }) password: string; + + /** + * Optional referral code captured from the shareable URL + * (e.g. /register?ref=MH-AAAA...) so the new user can be linked + * to the referrer for downstream rewards tracking. + */ + @IsOptional() + @IsString() + @MaxLength(64) + referralCode?: string; } diff --git a/backend/src/payments/payments.module.ts b/backend/src/payments/payments.module.ts index 7048d6f..d945e9f 100644 --- a/backend/src/payments/payments.module.ts +++ b/backend/src/payments/payments.module.ts @@ -18,6 +18,7 @@ import { BookingsModule } from '../bookings/bookings.module'; import { InvoicesModule } from '../invoices/invoices.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { PromoCodesModule } from '../promo-codes/promo-codes.module'; +import { ReferralsModule } from '../referrals/referrals.module'; @Module({ imports: [ @@ -26,6 +27,7 @@ import { PromoCodesModule } from '../promo-codes/promo-codes.module'; InvoicesModule, NotificationsModule, PromoCodesModule, + ReferralsModule, ], controllers: [PaymentsController], providers: [ diff --git a/backend/src/payments/providers/handle-webhook.provider.ts b/backend/src/payments/providers/handle-webhook.provider.ts index adb0f53..cc06be1 100644 --- a/backend/src/payments/providers/handle-webhook.provider.ts +++ b/backend/src/payments/providers/handle-webhook.provider.ts @@ -20,6 +20,7 @@ import { NotificationType } from '../../notifications/enums/notification-type.en import { User } from '../../users/entities/user.entity'; import { EmailService } from '../../email/email.service'; import { PromoCodesService } from '../../promo-codes/promo-codes.service'; +import { ReferralsService } from '../../referrals/referrals.service'; import { UserCredit } from '../../credits/entities/user-credit.entity'; import { UserCreditTransaction } from '../../credits/entities/credit-transaction.entity'; import { CreditTransactionType } from '../../credits/enums/credit-transaction-type.enum'; @@ -53,6 +54,7 @@ export class HandleWebhookProvider { private readonly emailService: EmailService, private readonly configService: ConfigService, private readonly promoCodesService: PromoCodesService, + private readonly referralsService: ReferralsService, ) {} async handle(rawBody: Buffer, signature: string): Promise { @@ -148,6 +150,18 @@ export class HandleWebhookProvider { }); } + // Referral "conversion" — completing a paid booking by a referred user + // marks that referral as COMPLETED and awards the default reward. + if (payment.userId) { + this.referralsService + .completeReferral(payment.userId) + .catch((err: Error) => { + this.logger.error( + `Failed to complete referral for user ${payment.userId}: ${err.message}`, + ); + }); + } + // Generate invoice asynchronously — do not block payment confirmation this.invoicesService.generateForPayment(payment.id).catch((err: Error) => { this.logger.error( diff --git a/backend/src/referrals/entities/referral.entity.ts b/backend/src/referrals/entities/referral.entity.ts new file mode 100644 index 0000000..bf9f209 --- /dev/null +++ b/backend/src/referrals/entities/referral.entity.ts @@ -0,0 +1,60 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +export enum ReferralStatus { + PENDING = 'pending', + COMPLETED = 'completed', +} +export enum RewardType { + DISCOUNT = 'discount', + CREDIT = 'credit', +} + +@Entity('referrals') +export class Referral { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + referrerId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'referrerId' }) + referrer: User; + + @Column({ type: 'uuid', nullable: true }) + referredUserId: string; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'referredUserId' }) + referredUser: User; + + @Column() + code: string; + + @Column({ + type: 'enum', + enum: ReferralStatus, + default: ReferralStatus.PENDING, + }) + status: ReferralStatus; + + @Column({ type: 'enum', enum: RewardType, nullable: true }) + rewardType: RewardType; + + @Column({ type: 'int', nullable: true }) + rewardValue: number; + + @Column({ type: 'timestamptz', nullable: true }) + awardedAt: Date; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/referrals/referrals.controller.ts b/backend/src/referrals/referrals.controller.ts index 45f4c06..cccd2c5 100644 --- a/backend/src/referrals/referrals.controller.ts +++ b/backend/src/referrals/referrals.controller.ts @@ -2,8 +2,10 @@ import { Controller, Get, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ReferralsService } from './referrals.service'; import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard'; -import { CurrentUser } from '../auth/decorators/current.user.decorators'; -import { User } from '../users/entities/user.entity'; +import { RolesGuard } from '../auth/guard/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorators'; +import { GetCurrentUser } from '../auth/decorators/getCurrentUser.decorator'; +import { UserRole } from '../users/enums/userRoles.enum'; @ApiTags('Referrals') @ApiBearerAuth() @@ -13,14 +15,19 @@ export class ReferralsController { constructor(private readonly service: ReferralsService) {} @Get('my-code') - async getMyCode(@CurrentUser() user: User) { - const data = await this.service.getMyCode(user.id); - return { data }; + async getMyCode(@GetCurrentUser('id') userId: string) { + return { data: await this.service.getMyCode(userId) }; } - @Get('history') - async getHistory(@CurrentUser() user: User) { - const data = await this.service.getHistory(user.id); - return { data }; + @Get('stats') + async getStats(@GetCurrentUser('id') userId: string) { + return { data: await this.service.getStats(userId) }; + } + + @Get() + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async getAll() { + return { data: await this.service.getAll() }; } } diff --git a/backend/src/referrals/referrals.module.ts b/backend/src/referrals/referrals.module.ts index 76c20aa..bb5d0f3 100644 --- a/backend/src/referrals/referrals.module.ts +++ b/backend/src/referrals/referrals.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '../users/entities/user.entity'; +import { Referral } from './entities/referral.entity'; import { ReferralsService } from './referrals.service'; import { ReferralsController } from './referrals.controller'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [TypeOrmModule.forFeature([User, Referral])], controllers: [ReferralsController], providers: [ReferralsService], + exports: [ReferralsService], }) export class ReferralsModule {} diff --git a/backend/src/referrals/referrals.service.spec.ts b/backend/src/referrals/referrals.service.spec.ts new file mode 100644 index 0000000..2cbf3b8 --- /dev/null +++ b/backend/src/referrals/referrals.service.spec.ts @@ -0,0 +1,192 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { ReferralsService } from './referrals.service'; +import { User } from '../users/entities/user.entity'; +import { + Referral, + ReferralStatus, + RewardType, +} from './entities/referral.entity'; + +type RepoMock = { + findOne: jest.Mock; + find: jest.Mock; + create: jest.Mock; + save: jest.Mock; + update: jest.Mock; +}; + +const buildUserRepo = (): RepoMock => ({ + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), +}); + +const buildReferralRepo = (): RepoMock => ({ + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn((x: Partial) => x as Referral), + save: jest.fn(), + update: jest.fn(), +}); + +describe('ReferralsService', () => { + let service: ReferralsService; + let userRepo: RepoMock; + let referralRepo: RepoMock; + + beforeEach(async () => { + userRepo = buildUserRepo(); + referralRepo = buildReferralRepo(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReferralsService, + { provide: getRepositoryToken(User), useValue: userRepo }, + { provide: getRepositoryToken(Referral), useValue: referralRepo }, + { + provide: ConfigService, + useValue: { get: jest.fn().mockReturnValue('https://test.app') }, + }, + ], + }).compile(); + + service = module.get(ReferralsService); + }); + + describe('ensureReferralCode', () => { + it('returns existing code without updating', async () => { + userRepo.findOne.mockResolvedValue({ + id: 'u1', + referralCode: 'MH-EXIST', + } as User); + + const code = await service.ensureReferralCode('u1'); + + expect(code).toBe('MH-EXIST'); + expect(userRepo.update).not.toHaveBeenCalled(); + }); + + it('generates and persists a new code when missing', async () => { + userRepo.findOne.mockResolvedValue({ + id: 'u1', + referralCode: null, + } as User); + userRepo.update.mockResolvedValue(undefined as never); + + const code = await service.ensureReferralCode('u1'); + + expect(code).toMatch(/^MH-[0-9A-F]{6}$/); + expect(userRepo.update).toHaveBeenCalledWith('u1', { + referralCode: code, + }); + }); + }); + + describe('getMyCode', () => { + it('returns code and shareable url', async () => { + userRepo.findOne.mockResolvedValue({ + id: 'u1', + referralCode: 'MH-CODE', + } as User); + + const result = await service.getMyCode('u1'); + + expect(result.referralCode).toBe('MH-CODE'); + expect(result.shareableUrl).toBe('https://test.app/register?ref=MH-CODE'); + }); + }); + + describe('createReferral', () => { + it('records a referral when referrer exists', async () => { + userRepo.findOne.mockResolvedValue({ + id: 'r1', + referralCode: 'MH-XYZ', + } as User); + referralRepo.findOne.mockResolvedValue(null); + + await service.createReferral('MH-XYZ', 'u2'); + + expect(referralRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + referrerId: 'r1', + referredUserId: 'u2', + code: 'MH-XYZ', + status: ReferralStatus.PENDING, + }), + ); + expect(referralRepo.save).toHaveBeenCalled(); + }); + + it('is a no-op when referrer not found', async () => { + userRepo.findOne.mockResolvedValue(null); + + await service.createReferral('MH-NOPE', 'u2'); + + expect(referralRepo.save).not.toHaveBeenCalled(); + }); + + it('is idempotent when referredUserId already has a referral', async () => { + userRepo.findOne.mockResolvedValue({ + id: 'r1', + referralCode: 'MH-XYZ', + } as User); + referralRepo.findOne.mockResolvedValue({ id: 'old' } as Referral); + + await service.createReferral('MH-XYZ', 'u2'); + + expect(referralRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('completeReferral', () => { + it('marks pending referral completed with default reward', async () => { + const pending = { + id: 'ref1', + status: ReferralStatus.PENDING, + rewardType: null, + rewardValue: null, + awardedAt: null, + } as unknown as Referral; + referralRepo.findOne.mockResolvedValue(pending); + referralRepo.save.mockResolvedValue(pending); + + await service.completeReferral('u2'); + + expect(pending.status).toBe(ReferralStatus.COMPLETED); + expect(pending.rewardType).toBe(RewardType.DISCOUNT); + expect(pending.rewardValue).toBe(10); + expect(pending.awardedAt).toBeInstanceOf(Date); + expect(referralRepo.save).toHaveBeenCalledWith(pending); + }); + + it('does nothing when no pending referral exists', async () => { + referralRepo.findOne.mockResolvedValue(null); + + await service.completeReferral('u2'); + + expect(referralRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('getStats', () => { + it('tallies total referrals, conversions and total rewards', async () => { + referralRepo.find.mockResolvedValue([ + { status: ReferralStatus.PENDING, rewardValue: 0 } as Referral, + { status: ReferralStatus.COMPLETED, rewardValue: 10 } as Referral, + { status: ReferralStatus.COMPLETED, rewardValue: 5 } as Referral, + ]); + + const stats = await service.getStats('u1'); + + expect(stats.totalReferrals).toBe(3); + expect(stats.successfulConversions).toBe(2); + expect(stats.totalRewardsEarned).toBe(15); + expect(stats.referrals).toHaveLength(3); + }); + }); +}); diff --git a/backend/src/referrals/referrals.service.ts b/backend/src/referrals/referrals.service.ts index d74b89a..c133a82 100644 --- a/backend/src/referrals/referrals.service.ts +++ b/backend/src/referrals/referrals.service.ts @@ -3,34 +3,115 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../users/entities/user.entity'; import { ConfigService } from '@nestjs/config'; +import { Referral, ReferralStatus, RewardType } from './entities/referral.entity'; +import { randomBytes } from 'crypto'; @Injectable() export class ReferralsService { constructor( - @InjectRepository(User) - private readonly userRepo: Repository, + @InjectRepository(User) private readonly userRepo: Repository, + @InjectRepository(Referral) + private readonly referralRepo: Repository, private readonly config: ConfigService, ) {} - async getMyCode(userId: string) { + private static readonly MAX_CODE_ATTEMPTS = 5; + + async ensureReferralCode(userId: string): Promise { const user = await this.userRepo.findOne({ where: { id: userId } }); - const referralCode = (user as any).referralCode ?? null; - const baseUrl = this.config.get('APP_URL') || 'https://managehub.app'; - const shareableUrl = referralCode ? `${baseUrl}/register?ref=${referralCode}` : null; + if (user?.referralCode) return user.referralCode; + for (let attempt = 0; attempt < ReferralsService.MAX_CODE_ATTEMPTS; attempt++) { + const code = `MH-${randomBytes(4).toString('hex').toUpperCase()}`; + try { + await this.userRepo.update(userId, { referralCode: code }); + return code; + } catch { + // Unique-constraint collision — retry with a new code. + if (attempt === ReferralsService.MAX_CODE_ATTEMPTS - 1) { + throw new Error( + `Failed to generate a unique referral code for user ${userId} after ${ReferralsService.MAX_CODE_ATTEMPTS} attempts`, + ); + } + } + } + // Unreachable, but TypeScript needs a definite return. + throw new Error('ensureReferralCode exited unexpectedly'); + } - const totalReferrals = await this.userRepo.count({ - where: { referredById: userId } as any, - }); + async getMyCode(userId: string) { + const code = await this.ensureReferralCode(userId); + const baseUrl = + this.config.get('FRONTEND_URL') || + this.config.get('APP_URL') || + 'https://managehub.vercel.app'; + return { + referralCode: code, + shareableUrl: `${baseUrl}/register?ref=${code}`, + }; + } - return { referralCode, shareableUrl, totalReferrals }; + async getStats(userId: string) { + const referrals = await this.referralRepo.find({ + where: { referrerId: userId }, + }); + const totalReferrals = referrals.length; + const conversions = referrals.filter( + (r) => r.status === ReferralStatus.COMPLETED, + ); + const rewardsEarned = conversions.reduce( + (sum, r) => sum + (r.rewardValue ?? 0), + 0, + ); + return { + totalReferrals, + successfulConversions: conversions.length, + totalRewardsEarned: rewardsEarned, + referrals, + }; } - async getHistory(userId: string) { - const referrals = await this.userRepo.find({ - where: { referredById: userId } as any, - select: ['id', 'firstname', 'lastname', 'email', 'createdAt'], + async getAll() { + return this.referralRepo.find({ + relations: ['referrer', 'referredUser'], order: { createdAt: 'DESC' }, }); - return referrals; + } + + async createReferral( + referralCode: string, + referredUserId: string, + ): Promise { + const referrer = await this.userRepo.findOne({ where: { referralCode } }); + if (!referrer) return; + // Idempotent: if this referred user already has a referral recorded, skip. + const existing = await this.referralRepo.findOne({ + where: { referredUserId }, + }); + if (existing) return; + await this.referralRepo.save( + this.referralRepo.create({ + referrerId: referrer.id, + referredUserId, + code: referralCode, + status: ReferralStatus.PENDING, + }), + ); + } + + /** + * Mark the first pending referral for a user as completed (i.e. "conversion"). + * Called from the payments webhook once the referred user's first booking is paid. + * Defaults the reward to a 10% discount if not previously set. + */ + async completeReferral(referredUserId: string): Promise { + const referral = await this.referralRepo.findOne({ + where: { referredUserId, status: ReferralStatus.PENDING }, + }); + if (!referral) return; + referral.status = ReferralStatus.COMPLETED; + referral.awardedAt = new Date(); + referral.rewardType = referral.rewardType ?? RewardType.DISCOUNT; + referral.rewardValue = referral.rewardValue ?? 10; + await this.referralRepo.save(referral); } } diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index 895389c..e978d4a 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -138,6 +138,9 @@ export class User { @Column({ type: 'int', default: 0 }) profileCompleteness: number; + @Column({ type: 'varchar', nullable: true, unique: true }) + referralCode?: string; + @DeleteDateColumn() deletedAt: Date; get fullName(): string {