diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index 7ab7a9c..29e68b4 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -34,6 +34,7 @@ describe('DonationsController', () => { create: jest.fn(), findPublic: jest.fn(), getTotalDonations: jest.fn(), + exportToCsv: jest.fn(), }; const mockRepository = { @@ -295,6 +296,24 @@ describe('DonationsController', () => { expect(result.total).toBe(0); }); }); + + describe('exportCsv', () => { + it('should call service exportToCsv method', async () => { + const mockStream = { + [Symbol.asyncIterator]: async function* () { + yield 'ID,First Name,Last Name,Email,Amount,Type,Interval,Date,Transaction ID\n'; + yield '1,John,Doe,john@example.com,100,one_time,,2024-01-01T00:00:00.000Z,txn_123\n'; + }, + }; + + mockService.exportToCsv = jest.fn().mockResolvedValue(mockStream); + + const result = await controller.exportCsv(); + + expect(service.exportToCsv).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + }); }); interface TestDonation { diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index cba94bc..06294de 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -9,6 +9,8 @@ import { ValidationPipe, HttpCode, HttpStatus, + StreamableFile, + Header, } from '@nestjs/common'; import { ApiTags, @@ -262,4 +264,27 @@ export class DonationsController { totalPages: result.totalPages, }; } + + @Get('export') + @UseGuards(AuthGuard('jwt')) + @ApiBearerAuth() + @Header('Content-Type', 'text/csv') + @Header('Content-Disposition', 'attachment; filename="donations.csv"') + @ApiOperation({ + summary: 'export donations to CSV (admin)', + description: + 'export all donations to a CSV file with streaming support. Requires authentication.', + }) + @ApiResponse({ + status: 200, + description: 'CSV file stream', + }) + @ApiResponse({ + status: 401, + description: 'unauthorized', + }) + async exportCsv(): Promise { + const stream = await this.donationsService.exportToCsv(); + return new StreamableFile(stream); + } } diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 8348fc7..97cba5b 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -404,4 +404,97 @@ describe('DonationsService', () => { ); }); }); + + describe('exportToCsv', () => { + it('should include all donation data in CSV rows', async () => { + const stream = await service.exportToCsv(); + + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + const csvContent = Buffer.concat(chunks).toString('utf-8'); + + const lines = csvContent.split('\n'); + expect(lines.length).toBe(4); // Header + 3 data rows + + // Check that donation data is present + expect(csvContent).toContain(validDonation1.firstName); + expect(csvContent).toContain(validDonation1.email); + expect(csvContent).toContain(String(validDonation1.amount)); + }); + + it('should handle empty donations list', async () => { + // Clear the in-memory donations + jest.spyOn(repo, 'find').mockResolvedValue([]); + + const stream = await service.exportToCsv(); + + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + const csvContent = Buffer.concat(chunks).toString('utf-8'); + + const lines = csvContent.split('\n'); + expect(lines.length).toBe(1); // Should only have header + expect(lines[0]).toBe( + 'ID,First Name,Last Name,Email,Amount,Type,Interval,Date,Transaction ID', + ); + }); + + it('should escape CSV fields with commas correctly', async () => { + const donationWithComma = { + ...validDonation1, + id: 999, + firstName: 'John, Jr.', + lastName: 'Smith, Sr.', + }; + + jest + .spyOn(repo, 'find') + .mockResolvedValue([donationWithComma as Donation]); + + const stream = await service.exportToCsv(); + + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + const csvContent = Buffer.concat(chunks).toString('utf-8'); + + // Fields with commas should be wrapped in quotes + expect(csvContent).toContain('"John, Jr."'); + expect(csvContent).toContain('"Smith, Sr."'); + }); + + it('should handle null/undefined values correctly', async () => { + const donationWithNulls = { + ...validDonation1, + id: 888, + recurringInterval: null, + transactionId: null, + }; + + jest + .spyOn(repo, 'find') + .mockResolvedValue([donationWithNulls as Donation]); + + const stream = await service.exportToCsv(); + + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + const csvContent = Buffer.concat(chunks).toString('utf-8'); + + const lines = csvContent.split('\n'); + const dataRow = lines[1]; + const fields = dataRow.split(','); + + // Null values should be empty strings + expect(fields[6]).toBe(''); // recurringInterval + expect(fields[8]).toBe(''); // transactionId + }); + }); }); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 3fac004..9ff5767 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -9,6 +9,7 @@ import { } from './donation.entity'; import { Repository } from 'typeorm'; import { CreateDonationRequest, Donation as DomainDonation } from './mappers'; +import { Readable } from 'stream'; interface PaymentIntentSyncPayload { donationId?: number; @@ -261,4 +262,58 @@ export class DonationsService { await this.donationRepository.save(donation); } + + async exportToCsv(): Promise { + const donations = await this.donationRepository.find(); + const headers = [ + 'ID', + 'First Name', + 'Last Name', + 'Email', + 'Amount', + 'Type', + 'Interval', + 'Date', + 'Transaction ID', + ]; + + // Helper function to escape CSV fields + const escapeCsvField = ( + field: string | number | null | undefined, + ): string => { + if (field === null || field === undefined) { + return ''; + } + const stringValue = String(field); + // If the field contains comma, quote, or newline, wrap in quotes and escape quotes + if ( + stringValue.includes(',') || + stringValue.includes('"') || + stringValue.includes('\n') + ) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + return stringValue; + }; + + const csvRows: string[] = [headers.join(',')]; + for (const donation of donations) { + const row = [ + escapeCsvField(donation.id), + escapeCsvField(donation.firstName), + escapeCsvField(donation.lastName), + escapeCsvField(donation.email), + escapeCsvField(donation.amount), + escapeCsvField(donation.donationType), + escapeCsvField(donation.recurringInterval), + escapeCsvField(donation.createdAt.toISOString()), + escapeCsvField(donation.transactionId), + ]; + csvRows.push(row.join(',')); + } + const csvContent = csvRows.join('\n'); + const stream = Readable.from([csvContent]); + + return stream; + } } diff --git a/nx.json b/nx.json index bee425e..9ed0636 100644 --- a/nx.json +++ b/nx.json @@ -58,6 +58,5 @@ } } }, - "nxCloudAccessToken": "OTc0MjgyZDgtMTYzYy00OTYwLTg5NWEtNDQ5YTg2ZjJkMDU2fHJlYWQtd3JpdGU=", "useInferencePlugins": false }