diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index 547aefe..8de48f5 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, @@ -37,6 +39,7 @@ describe('DonationsController', () => { findPublic: jest.fn(), getTotalDonations: jest.fn(), exportToCsv: jest.fn(), + getLapsedDonors: jest.fn(), }; const mockRepository = { @@ -74,11 +77,17 @@ describe('DonationsController', () => { useValue: mockUsersService, }, ], - }).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(() => { @@ -169,6 +178,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 0a605f3..74f3a56 100644 --- a/apps/backend/src/donations/donations.repository.spec.ts +++ b/apps/backend/src/donations/donations.repository.spec.ts @@ -32,6 +32,13 @@ describe('DonationsRepository', () => { }; beforeEach(async () => { + 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>; // Create mock query builder with all necessary methods mockQueryBuilder = { where: jest.fn().mockReturnThis(), @@ -43,9 +50,19 @@ 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(), + + // used by findLapsedDonors + getRawMany: jest.fn(), } as unknown as jest.Mocked>; // Create mock TypeORM repository @@ -217,6 +234,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]; @@ -379,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 = [