Skip to content
100 changes: 88 additions & 12 deletions serverless/app/handlers/__tests__/export.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { handler } from '../export';
import { BatchHelper, Key } from '../../../lib/StorageClient';
import * as XLSX from 'xlsx';
import ExcelJS from 'exceljs';

// Mock dependencies
jest.mock('../../../lib/StorageClient', () => ({
Expand All @@ -14,13 +14,32 @@ jest.mock('../../../lib/StorageClient', () => ({
}),
},
}));
jest.mock('xlsx', () => ({
utils: {
book_new: jest.fn(),
json_to_sheet: jest.fn().mockReturnValue({}),
book_append_sheet: jest.fn(),
const mockCells = new Map<string, { value?: unknown; font?: unknown }>();
const mockWorksheet = {
columns: [] as unknown[],
addRows: jest.fn(),
getRow: jest.fn((rowNumber: number) => ({
getCell: jest.fn((columnNumber: number) => {
const key = `${rowNumber}:${columnNumber}`;
if (!mockCells.has(key)) {
mockCells.set(key, {});
}
return mockCells.get(key)!;
}),
})),
};
const writeBufferMock = jest.fn().mockResolvedValue(Buffer.from('mock-excel-content'));
const mockWorkbook = {
addWorksheet: jest.fn().mockReturnValue(mockWorksheet),
xlsx: {
writeBuffer: writeBufferMock,
},
};
jest.mock('exceljs', () => ({
__esModule: true,
default: {
Workbook: jest.fn().mockImplementation(() => mockWorkbook),
},
write: jest.fn().mockReturnValue(Buffer.from('mock-excel-content')),
}));

describe('export handler', () => {
Expand All @@ -31,6 +50,9 @@ describe('export handler', () => {

beforeEach(() => {
jest.clearAllMocks();
mockCells.clear();
mockWorksheet.columns = [];
process.env.PORTAL_CASE_URL = 'https://portal.example.com/search-results';
});

it('should return 400 if body is missing', async () => {
Expand Down Expand Up @@ -68,6 +90,7 @@ describe('export handler', () => {
};

const mockZipCase = {
caseId: 'case-id-123',
fetchStatus: { status: 'complete' },
};

Expand All @@ -89,9 +112,11 @@ describe('export handler', () => {
},
isBase64Encoded: true,
});
expect(ExcelJS.Workbook).toHaveBeenCalledTimes(1);
expect(writeBufferMock).toHaveBeenCalledTimes(1);

// Verify XLSX calls
expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([
// Verify worksheet rows
expect(mockWorksheet.addRows).toHaveBeenCalledWith([
{
'Case Number': 'CASE123',
'Court Name': 'Test Court',
Expand Down Expand Up @@ -124,7 +149,7 @@ describe('export handler', () => {

await handler(mockEvent({ caseNumbers: mockCaseNumbers }), {} as any, {} as any);

expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([
expect(mockWorksheet.addRows).toHaveBeenCalledWith([
expect.objectContaining({
'Case Number': 'CASE_FAILED',
Notes: 'Failed to load case data',
Expand Down Expand Up @@ -155,7 +180,7 @@ describe('export handler', () => {

await handler(mockEvent({ caseNumbers: mockCaseNumbers }), {} as any, {} as any);

expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([
expect(mockWorksheet.addRows).toHaveBeenCalledWith([
expect.objectContaining({
'Case Number': 'CASE_NO_CHARGES',
Notes: 'No charges found',
Expand Down Expand Up @@ -191,7 +216,7 @@ describe('export handler', () => {

await handler(mockEvent({ caseNumbers: mockCaseNumbers }), {} as any, {} as any);

const calls = (XLSX.utils.json_to_sheet as jest.Mock).mock.calls[0][0];
const calls = (mockWorksheet.addRows as jest.Mock).mock.calls[0][0];
const levels = calls.map((row: any) => row['Offense Level']);

expect(levels).toEqual(['M1', '', 'GL M', 'T', 'INF']);
Expand Down Expand Up @@ -220,4 +245,55 @@ describe('export handler', () => {
},
});
});

it('should create clickable hyperlink for case number cells', async () => {
const mockCaseNumbers = ['CASE123'];

const mockSummary = { charges: [] };
const mockZipCase = { caseId: 'case-id-123', fetchStatus: { status: 'complete' } };
(BatchHelper.getMany as jest.Mock).mockImplementation(async (keys: any[]) => {
const map = new Map();
keys.forEach(key => {
if (key.PK === 'CASE#CASE123' && key.SK === 'SUMMARY') map.set(key, mockSummary);
if (key.PK === 'CASE#CASE123' && key.SK === 'ID') map.set(key, mockZipCase);
});
return map;
});

await handler(mockEvent({ caseNumbers: mockCaseNumbers }), {} as any, {} as any);

expect(mockWorksheet.getRow).toHaveBeenCalledWith(2);
const caseNumberCell = mockCells.get('2:1');
expect(caseNumberCell?.value).toEqual({
text: 'CASE123',
hyperlink: 'https://portal.example.com/search-results/#/case-id-123',
});
expect(caseNumberCell?.font).toMatchObject({
color: { argb: 'FF0563C1' },
underline: true,
});
});

it('should keep text value and hyperlink relationship for quoted case numbers', async () => {
const mockCaseNumbers = ['CASE"123'];

const mockSummary = { charges: [] };
const mockZipCase = { caseId: 'case"id-123', fetchStatus: { status: 'complete' } };
(BatchHelper.getMany as jest.Mock).mockImplementation(async (keys: any[]) => {
const map = new Map();
keys.forEach(key => {
if (key.PK === 'CASE#CASE"123' && key.SK === 'SUMMARY') map.set(key, mockSummary);
if (key.PK === 'CASE#CASE"123' && key.SK === 'ID') map.set(key, mockZipCase);
});
return map;
});

await handler(mockEvent({ caseNumbers: mockCaseNumbers }), {} as any, {} as any);

const caseNumberCell = mockCells.get('2:1');
expect(caseNumberCell?.value).toEqual({
text: 'CASE"123',
hyperlink: 'https://portal.example.com/search-results/#/case"id-123',
});
});
});
75 changes: 55 additions & 20 deletions serverless/app/handlers/export.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { APIGatewayProxyHandler } from 'aws-lambda';
import * as XLSX from 'xlsx';
import ExcelJS from 'exceljs';
import { BatchHelper, Key } from '../../lib/StorageClient';
import { CaseSummary, Disposition, ZipCase } from '../../../shared/types';

Expand Down Expand Up @@ -47,6 +47,7 @@ export const handler: APIGatewayProxyHandler = async event => {
const dataMap = await BatchHelper.getMany<CaseSummary | ZipCase>(allKeys);

const rows: ExportRow[] = [];
const caseNumberToUrlMap = new Map<string, string>();

for (const caseNumber of caseNumbers) {
const summaryKey = Key.Case(caseNumber).SUMMARY;
Expand All @@ -64,6 +65,11 @@ export const handler: APIGatewayProxyHandler = async event => {
continue;
}

const caseUrl = zipCase.caseId && process.env.PORTAL_CASE_URL ? `${process.env.PORTAL_CASE_URL}/#/${zipCase.caseId}` : '';
if (caseUrl) {
caseNumberToUrlMap.set(caseNumber, caseUrl);
}

// Handle failed cases and those without summaries
if (!summary || zipCase.fetchStatus.status === 'failed') {
rows.push({
Expand Down Expand Up @@ -124,29 +130,58 @@ export const handler: APIGatewayProxyHandler = async event => {
}

// Create workbook and worksheet
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(rows);

// Auto-fit columns
if (rows.length > 0) {
const headers = Object.keys(rows[0]);
const colWidths = headers.map(key => {
let maxLength = key.length;
rows.forEach(row => {
const val = row[key as keyof ExportRow];
const len = val ? String(val).length : 0;
if (len > maxLength) maxLength = len;
});
// Cap the width at 50 to prevent massive columns, but ensure at least 10
return { wch: Math.min(Math.max(maxLength + 2, 10), 50) };
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Cases');

const headers: (keyof ExportRow)[] = [
'Case Number',
'Court Name',
'Arrest Date',
'Offense Description',
'Offense Level',
'Offense Date',
'Disposition',
'Disposition Date',
'Arresting Agency',
'Notes',
];

const colWidths = headers.map(key => {
let maxLength = key.length;
rows.forEach(row => {
const val = row[key];
const len = val ? String(val).length : 0;
if (len > maxLength) maxLength = len;
});
return Math.min(Math.max(maxLength + 2, 10), 50);
});

ws.columns = headers.map((header, idx) => ({
header,
key: header,
width: colWidths[idx],
}));
ws.addRows(rows);

const caseNumberColumn = headers.indexOf('Case Number') + 1;
if (caseNumberColumn > 0) {
rows.forEach((row, idx) => {
const caseNumber = row['Case Number'];
const caseUrl = caseNumberToUrlMap.get(caseNumber);
if (caseUrl) {
const caseNumberCell = ws.getRow(idx + 2).getCell(caseNumberColumn);
caseNumberCell.value = { text: caseNumber, hyperlink: caseUrl };
caseNumberCell.font = {
...(caseNumberCell.font || {}),
color: { argb: 'FF0563C1' },
underline: true,
};
}
});
ws['!cols'] = colWidths;
}

XLSX.utils.book_append_sheet(wb, ws, 'Cases');

// Generate buffer
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
const buffer = Buffer.from(await wb.xlsx.writeBuffer());

const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0];
const filename = `ZipCase-Export-${timestamp}.xlsx`;
Expand Down
Loading