From ee987ca262724b329308e8f7e6a8d2104a07b040 Mon Sep 17 00:00:00 2001 From: pujitakalinadhabhotla <161961520+pujitakalinadhabhotla@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:37:06 -0500 Subject: [PATCH 1/2] Update donations.repository.spec.ts --- .../donations/donations.repository.spec.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/apps/backend/src/donations/donations.repository.spec.ts b/apps/backend/src/donations/donations.repository.spec.ts index 0a605f3..62f5af2 100644 --- a/apps/backend/src/donations/donations.repository.spec.ts +++ b/apps/backend/src/donations/donations.repository.spec.ts @@ -33,6 +33,14 @@ describe('DonationsRepository', () => { beforeEach(async () => { // Create mock query builder with all necessary methods + const mockSubQueryBuilder = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getQuery: jest.fn().mockReturnValue('(subquery)'), + } as unknown as jest.Mocked>; + mockQueryBuilder = { where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -46,6 +54,11 @@ describe('DonationsRepository', () => { getManyAndCount: jest.fn(), getMany: jest.fn(), getRawOne: jest.fn(), + subQuery: jest.fn().mockReturnValue(mockSubQueryBuilder), + setParameter: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + having: jest.fn().mockReturnThis(), + getRawMany: jest.fn(), } as unknown as jest.Mocked>; // Create mock TypeORM repository @@ -217,6 +230,70 @@ describe('DonationsRepository', () => { }); }); + describe('findLapsedDonors', () => { + it('should filter to SUCCEEDED donations and apply cutoff via HAVING MAX(createdAt)', async () => { + mockQueryBuilder.getRawMany.mockResolvedValue([ + { email: 'alice@example.com' }, + ]); + + const result = await repository.findLapsedDonors(6); + + expect(mockTypeOrmRepo.createQueryBuilder).toHaveBeenCalledWith( + 'donation', + ); + + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'donation.status = :succeededStatus', + { succeededStatus: DonationStatus.SUCCEEDED }, + ); + + expect(mockQueryBuilder.having).toHaveBeenCalledWith( + 'MAX(donation.createdAt) < :cutoff', + expect.objectContaining({ cutoff: expect.any(Date) }), + ); + + expect(result).toEqual(['alice@example.com']); + }); + + it('should return unique, normalized emails (trim + lowercase)', async () => { + mockQueryBuilder.getRawMany.mockResolvedValue([ + { email: 'ALICE@EXAMPLE.COM' }, + { email: 'alice@example.com' }, + { email: ' Alice@Example.com ' }, + ]); + + const result = await repository.findLapsedDonors(6); + + expect(result).toEqual(['alice@example.com']); + }); + + it('should exclude donors with recurring donations using NOT EXISTS subquery', async () => { + mockQueryBuilder.getRawMany.mockResolvedValue([ + { email: 'x@example.com' }, + ]); + + await repository.findLapsedDonors(6); + + expect(mockQueryBuilder.subQuery).toHaveBeenCalled(); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + expect.stringContaining('NOT EXISTS'), + ); + expect(mockQueryBuilder.setParameter).toHaveBeenCalledWith( + 'recurringType', + DonationType.RECURRING, + ); + }); + + it('should throw when numMonths is not positive', async () => { + await expect(repository.findLapsedDonors(0)).rejects.toThrow( + 'numMonths must be a positive number', + ); + await expect(repository.findLapsedDonors(-1)).rejects.toThrow( + 'numMonths must be a positive number', + ); + }); + }); + describe('searchByDonorNameOrEmail', () => { it('should search by donor name or email with default limit', async () => { const mockResults = [mockDonation]; From 6470b4481aec86e5029ce2c12df9cf83a806048d Mon Sep 17 00:00:00 2001 From: pujitakalinadhabhotla <161961520+pujitakalinadhabhotla@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:25:01 -0500 Subject: [PATCH 2/2] Add lapsed donor logic and associated controller, service, and repository tests --- .../donations/donations.controller.spec.ts | 51 ++++++++--- .../src/donations/donations.controller.ts | 34 ++++++++ .../donations/donations.repository.spec.ts | 84 +++++++++++++++++-- .../src/donations/donations.repository.ts | 41 +++++++++ .../src/donations/donations.service.spec.ts | 54 +++++++++--- .../src/donations/donations.service.ts | 11 +++ 6 files changed, 245 insertions(+), 30 deletions(-) diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index daedd80..01807fd 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -3,6 +3,8 @@ import { DonationsController } from './donations.controller'; import { DonationsService } from './donations.service'; import { DonationsRepository } from './donations.repository'; import { CreateDonationDto } from './dtos/create-donation-dto'; +import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { DonationType, RecurringInterval, @@ -35,6 +37,7 @@ describe('DonationsController', () => { findPublic: jest.fn(), getTotalDonations: jest.fn(), exportToCsv: jest.fn(), + getLapsedDonors: jest.fn(), }; const mockRepository = { @@ -45,20 +48,20 @@ describe('DonationsController', () => { const module: TestingModule = await Test.createTestingModule({ controllers: [DonationsController], providers: [ - { - provide: DonationsService, - useValue: mockService, - }, - { - provide: DonationsRepository, - useValue: mockRepository, - }, + { provide: DonationsService, useValue: mockService }, + { provide: DonationsRepository, useValue: mockRepository }, ], - }).compile(); - - controller = module.get(DonationsController); - service = module.get(DonationsService); - repository = module.get(DonationsRepository); + }) + .overrideInterceptor(CurrentUserInterceptor) + .useValue({ + intercept: (_context: ExecutionContext, next: CallHandler) => + next.handle(), + }) + .compile(); + + controller = module.get(DonationsController); + service = module.get(DonationsService); + repository = module.get(DonationsRepository); }); afterEach(() => { @@ -149,6 +152,28 @@ describe('DonationsController', () => { }); }); + describe('getLapsedDonors', () => { + it('should call service.getLapsedDonors with provided numMonths', async () => { + mockService.getLapsedDonors.mockResolvedValue({ + emails: ['a@example.com'], + }); + + const result = await controller.getLapsedDonors(9); + + expect(service.getLapsedDonors).toHaveBeenCalledWith(9); + expect(result).toEqual({ emails: ['a@example.com'] }); + }); + + it('should default numMonths to 6 when not provided', async () => { + mockService.getLapsedDonors.mockResolvedValue({ emails: [] }); + + const result = await controller.getLapsedDonors(undefined); + + expect(service.getLapsedDonors).toHaveBeenCalledWith(6); + expect(result).toEqual({ emails: [] }); + }); + }); + describe('findPublic', () => { it('should return public donations', async () => { mockService.findPublic.mockResolvedValue([mockDomainDonation]); diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 9fdc870..a7399d1 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -125,6 +125,40 @@ export class DonationsController { return stats; } + @Get('lapsed') + @ApiOperation({ + summary: 'get lapsed donor emails', + description: + 'retrieve unique donor emails for donors who have not donated successfully in numMonths months and do not have recurring donations', + }) + @ApiQuery({ + name: 'numMonths', + required: false, + type: Number, + description: 'number of months since last successful donation', + example: 6, + }) + @ApiResponse({ + status: 200, + description: 'list of lapsed donor emails', + schema: { + type: 'object', + properties: { + emails: { + type: 'array', + items: { type: 'string' }, + example: ['alice@example.com', 'bob@example.com'], + }, + }, + }, + }) + async getLapsedDonors( + @Query('numMonths', new ParseIntPipe({ optional: true })) + numMonths?: number, + ): Promise<{ emails: string[] }> { + return this.donationsService.getLapsedDonors(numMonths ?? 6); + } + @Get() @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() diff --git a/apps/backend/src/donations/donations.repository.spec.ts b/apps/backend/src/donations/donations.repository.spec.ts index 62f5af2..74f3a56 100644 --- a/apps/backend/src/donations/donations.repository.spec.ts +++ b/apps/backend/src/donations/donations.repository.spec.ts @@ -32,7 +32,6 @@ describe('DonationsRepository', () => { }; beforeEach(async () => { - // Create mock query builder with all necessary methods const mockSubQueryBuilder = { select: jest.fn().mockReturnThis(), from: jest.fn().mockReturnThis(), @@ -40,7 +39,7 @@ describe('DonationsRepository', () => { andWhere: jest.fn().mockReturnThis(), getQuery: jest.fn().mockReturnValue('(subquery)'), } as unknown as jest.Mocked>; - + // Create mock query builder with all necessary methods mockQueryBuilder = { where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -51,13 +50,18 @@ describe('DonationsRepository', () => { limit: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), addSelect: jest.fn().mockReturnThis(), + + // added for findLapsedDonors + groupBy: jest.fn().mockReturnThis(), + having: jest.fn().mockReturnThis(), + setParameter: jest.fn().mockReturnThis(), + subQuery: jest.fn().mockReturnValue(mockSubQueryBuilder), + getManyAndCount: jest.fn(), getMany: jest.fn(), getRawOne: jest.fn(), - subQuery: jest.fn().mockReturnValue(mockSubQueryBuilder), - setParameter: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - having: jest.fn().mockReturnThis(), + + // used by findLapsedDonors getRawMany: jest.fn(), } as unknown as jest.Mocked>; @@ -456,6 +460,74 @@ describe('DonationsRepository', () => { }); }); + describe('findLapsedDonors', () => { + it('should filter to SUCCEEDED donations and apply cutoff via HAVING MAX(createdAt)', async () => { + mockQueryBuilder.getRawMany.mockResolvedValue([ + { email: 'alice@example.com' }, + ]); + + const result = await repository.findLapsedDonors(6); + + expect(mockTypeOrmRepo.createQueryBuilder).toHaveBeenCalledWith( + 'donation', + ); + + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'donation.status = :succeededStatus', + { succeededStatus: DonationStatus.SUCCEEDED }, + ); + + expect(mockQueryBuilder.having).toHaveBeenCalledWith( + 'MAX(donation.createdAt) < :cutoff', + expect.objectContaining({ + cutoff: expect.any(Date), + }), + ); + + expect(result).toEqual(['alice@example.com']); + }); + + it('should return unique, normalized emails (trim + lowercase)', async () => { + mockQueryBuilder.getRawMany.mockResolvedValue([ + { email: 'ALICE@EXAMPLE.COM' }, + { email: 'alice@example.com' }, + { email: ' Alice@Example.com ' }, + ]); + + const result = await repository.findLapsedDonors(6); + + expect(result).toEqual(['alice@example.com']); + }); + + it('should exclude donors with recurring donations using NOT EXISTS subquery', async () => { + mockQueryBuilder.getRawMany.mockResolvedValue([ + { email: 'x@example.com' }, + ]); + + await repository.findLapsedDonors(6); + + expect(mockQueryBuilder.subQuery).toHaveBeenCalled(); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + expect.stringContaining('NOT EXISTS'), + ); + + expect(mockQueryBuilder.setParameter).toHaveBeenCalledWith( + 'recurringType', + DonationType.RECURRING, + ); + }); + + it('should throw when numMonths is not positive', async () => { + await expect(repository.findLapsedDonors(0)).rejects.toThrow( + 'numMonths must be a positive number', + ); + await expect(repository.findLapsedDonors(-2)).rejects.toThrow( + 'numMonths must be a positive number', + ); + }); + }); + describe('deleteById', () => { it('should delete donation by id', async () => { mockTypeOrmRepo.delete.mockResolvedValue({ diff --git a/apps/backend/src/donations/donations.repository.ts b/apps/backend/src/donations/donations.repository.ts index daac4d3..08f548a 100644 --- a/apps/backend/src/donations/donations.repository.ts +++ b/apps/backend/src/donations/donations.repository.ts @@ -188,6 +188,47 @@ export class DonationsRepository { ); } + /** + * Find unique donor emails that have not made a successful donation in `numMonths` months, + * and do NOT have any recurring donation record. + * + * - Only considers SUCCEEDED donations for "last donated" logic + * - Excludes donors with at least one recurring donation (any status) + */ + async findLapsedDonors(numMonths: number): Promise { + if (!Number.isFinite(numMonths) || numMonths <= 0) { + throw new Error('numMonths must be a positive number'); + } + + const cutoff = new Date(); + cutoff.setMonth(cutoff.getMonth() - numMonths); + + const qb = this.repository.createQueryBuilder('donation'); + + const recurringExistsSubquery = qb + .subQuery() + .select('1') + .from(Donation, 'recurringDonation') + .where('LOWER(recurringDonation.email) = LOWER(donation.email)') + .andWhere('recurringDonation.donationType = :recurringType') + .getQuery(); + + const rows = await qb + .select('LOWER(donation.email)', 'email') + .where('donation.status = :succeededStatus', { + succeededStatus: DonationStatus.SUCCEEDED, + }) + .andWhere('donation.email IS NOT NULL') + .andWhere("donation.email <> ''") + .andWhere(`NOT EXISTS ${recurringExistsSubquery}`) + .setParameter('recurringType', DonationType.RECURRING) + .groupBy('LOWER(donation.email)') + .having('MAX(donation.createdAt) < :cutoff', { cutoff }) + .getRawMany<{ email: string }>(); + + return [...new Set(rows.map((r) => r.email.trim().toLowerCase()))]; + } + /** * Delete a donation by ID (admin-only destructive operation) */ diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 97cba5b..e01d41a 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -6,7 +6,7 @@ import { DonationType, Donation, DonationStatus } from './donation.entity'; import { DonationsService } from './donations.service'; import { CreateDonationRequest } from './mappers'; import { DonationResponseDto } from './dtos/donation-response-dto'; - +import { DonationsRepository } from './donations.repository'; // mock donations // invalid donation: non positive donation amount @@ -216,7 +216,9 @@ const expectedDonations: DonationResponseDto[] = allDonations.map( describe('DonationsService', () => { let service: DonationsService; let repo: jest.Mocked>>; - + let mockDonationsRepository: jest.Mocked< + Pick + >; beforeAll(async () => { const repoMock = { manager: { @@ -236,10 +238,16 @@ describe('DonationsService', () => { findOne: jest.fn(), } as unknown as jest.Mocked>>; + mockDonationsRepository = { + findLapsedDonors: jest.fn(), + }; + const app = await Test.createTestingModule({ providers: [ DonationsService, { provide: getRepositoryToken(Donation), useValue: repoMock }, + + { provide: DonationsRepository, useValue: mockDonationsRepository }, ], }).compile(); @@ -249,24 +257,18 @@ describe('DonationsService', () => { repo.findOne.mockImplementation( async (options?: FindOneOptions) => { const where = options?.where as FindOptionsWhere | undefined; - if (!where) { - return null; - } + if (!where) return null; if (where.id !== undefined && where.id !== null) { const donation = allDonations.find((d) => d.id === where.id); - if (donation) { - return donation; - } + if (donation) return donation; } if (where.transactionId) { const donation = allDonations.find( (d) => d.transactionId === where.transactionId, ); - if (donation) { - return donation; - } + if (donation) return donation; } return null; @@ -330,6 +332,36 @@ describe('DonationsService', () => { }); }); + describe('getLapsedDonors', () => { + it('should call repository.findLapsedDonors with numMonths', async () => { + mockDonationsRepository.findLapsedDonors.mockResolvedValue([ + 'a@example.com', + 'b@example.com', + ]); + + const result = await service.getLapsedDonors(9); + + expect(mockDonationsRepository.findLapsedDonors).toHaveBeenCalledWith(9); + expect(result).toEqual({ + emails: ['a@example.com', 'b@example.com'], + }); + }); + + it('should default to 6 months if numMonths is undefined', async () => { + mockDonationsRepository.findLapsedDonors.mockResolvedValue([]); + + const result = await service.getLapsedDonors(); + + expect(mockDonationsRepository.findLapsedDonors).toHaveBeenCalledWith(6); + expect(result).toEqual({ emails: [] }); + }); + + it('should throw if numMonths is not positive', async () => { + await expect(service.getLapsedDonors(0)).rejects.toThrow(); + await expect(service.getLapsedDonors(-3)).rejects.toThrow(); + }); + }); + describe('Find public donations method', () => { it('should return all public donations as domain objects', async () => { const publicDonations = await service.findPublic(); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 9ff5767..2e9997f 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -10,6 +10,7 @@ import { import { Repository } from 'typeorm'; import { CreateDonationRequest, Donation as DomainDonation } from './mappers'; import { Readable } from 'stream'; +import { DonationsRepository } from './donations.repository'; interface PaymentIntentSyncPayload { donationId?: number; @@ -24,6 +25,7 @@ export class DonationsService { constructor( @InjectRepository(Donation) private donationRepository: Repository, + private readonly donationsRepository: DonationsRepository, ) {} async create( @@ -263,6 +265,15 @@ export class DonationsService { await this.donationRepository.save(donation); } + async getLapsedDonors(numMonths = 6): Promise<{ emails: string[] }> { + if (!Number.isFinite(numMonths) || numMonths <= 0) { + throw new BadRequestException('numMonths must be a positive number'); + } + + const emails = await this.donationsRepository.findLapsedDonors(numMonths); + return { emails }; + } + async exportToCsv(): Promise { const donations = await this.donationRepository.find(); const headers = [