Skip to content
Merged
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
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