Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/backend/src/donations/donations.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe('DonationsController', () => {
create: jest.fn(),
findPublic: jest.fn(),
getTotalDonations: jest.fn(),
exportToCsv: jest.fn(),
};

const mockRepository = {
Expand Down Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions apps/backend/src/donations/donations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
ValidationPipe,
HttpCode,
HttpStatus,
StreamableFile,
Header,
} from '@nestjs/common';
import {
ApiTags,
Expand Down Expand Up @@ -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<StreamableFile> {
const stream = await this.donationsService.exportToCsv();
return new StreamableFile(stream);
}
}
93 changes: 93 additions & 0 deletions apps/backend/src/donations/donations.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
});
55 changes: 55 additions & 0 deletions apps/backend/src/donations/donations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -261,4 +262,58 @@ export class DonationsService {

await this.donationRepository.save(donation);
}

async exportToCsv(): Promise<Readable> {
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;
}
}
1 change: 0 additions & 1 deletion nx.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,5 @@
}
}
},
"nxCloudAccessToken": "OTc0MjgyZDgtMTYzYy00OTYwLTg5NWEtNDQ5YTg2ZjJkMDU2fHJlYWQtd3JpdGU=",
"useInferencePlugins": false
}