Skip to content

Commit c86ef94

Browse files
Copilotjayhill
authored andcommitted
#173 add clickable case URL hyperlinks to xlsx export
1 parent 55fcf49 commit c86ef94

4 files changed

Lines changed: 926 additions & 113 deletions

File tree

serverless/app/handlers/__tests__/export.test.ts

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { handler } from '../export';
22
import { BatchHelper, Key } from '../../../lib/StorageClient';
3-
import * as XLSX from 'xlsx';
3+
import ExcelJS from 'exceljs';
44

55
// Mock dependencies
66
jest.mock('../../../lib/StorageClient', () => ({
@@ -14,13 +14,32 @@ jest.mock('../../../lib/StorageClient', () => ({
1414
}),
1515
},
1616
}));
17-
jest.mock('xlsx', () => ({
18-
utils: {
19-
book_new: jest.fn(),
20-
json_to_sheet: jest.fn().mockReturnValue({}),
21-
book_append_sheet: jest.fn(),
17+
const mockCells = new Map<string, { value?: unknown; font?: unknown }>();
18+
const mockWorksheet = {
19+
columns: [] as unknown[],
20+
addRows: jest.fn(),
21+
getRow: jest.fn((rowNumber: number) => ({
22+
getCell: jest.fn((columnNumber: number) => {
23+
const key = `${rowNumber}:${columnNumber}`;
24+
if (!mockCells.has(key)) {
25+
mockCells.set(key, {});
26+
}
27+
return mockCells.get(key)!;
28+
}),
29+
})),
30+
};
31+
const writeBufferMock = jest.fn().mockResolvedValue(Buffer.from('mock-excel-content'));
32+
const mockWorkbook = {
33+
addWorksheet: jest.fn().mockReturnValue(mockWorksheet),
34+
xlsx: {
35+
writeBuffer: writeBufferMock,
36+
},
37+
};
38+
jest.mock('exceljs', () => ({
39+
__esModule: true,
40+
default: {
41+
Workbook: jest.fn().mockImplementation(() => mockWorkbook),
2242
},
23-
write: jest.fn().mockReturnValue(Buffer.from('mock-excel-content')),
2443
}));
2544

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

3251
beforeEach(() => {
3352
jest.clearAllMocks();
53+
mockCells.clear();
54+
mockWorksheet.columns = [];
55+
process.env.PORTAL_CASE_URL = 'https://portal.example.com/search-results';
3456
});
3557

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

7092
const mockZipCase = {
93+
caseId: 'case-id-123',
7194
fetchStatus: { status: 'complete' },
7295
};
7396

@@ -89,9 +112,11 @@ describe('export handler', () => {
89112
},
90113
isBase64Encoded: true,
91114
});
115+
expect(ExcelJS.Workbook).toHaveBeenCalledTimes(1);
116+
expect(writeBufferMock).toHaveBeenCalledTimes(1);
92117

93-
// Verify XLSX calls
94-
expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([
118+
// Verify worksheet rows
119+
expect(mockWorksheet.addRows).toHaveBeenCalledWith([
95120
{
96121
'Case Number': 'CASE123',
97122
'Court Name': 'Test Court',
@@ -124,7 +149,7 @@ describe('export handler', () => {
124149

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

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

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

158-
expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([
183+
expect(mockWorksheet.addRows).toHaveBeenCalledWith([
159184
expect.objectContaining({
160185
'Case Number': 'CASE_NO_CHARGES',
161186
Notes: 'No charges found',
@@ -191,7 +216,7 @@ describe('export handler', () => {
191216

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

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

197222
expect(levels).toEqual(['M1', '', 'GL M', 'T', 'INF']);
@@ -220,4 +245,55 @@ describe('export handler', () => {
220245
},
221246
});
222247
});
248+
249+
it('should create clickable hyperlink for case number cells', async () => {
250+
const mockCaseNumbers = ['CASE123'];
251+
252+
const mockSummary = { charges: [] };
253+
const mockZipCase = { caseId: 'case-id-123', fetchStatus: { status: 'complete' } };
254+
(BatchHelper.getMany as jest.Mock).mockImplementation(async (keys: any[]) => {
255+
const map = new Map();
256+
keys.forEach(key => {
257+
if (key.PK === 'CASE#CASE123' && key.SK === 'SUMMARY') map.set(key, mockSummary);
258+
if (key.PK === 'CASE#CASE123' && key.SK === 'ID') map.set(key, mockZipCase);
259+
});
260+
return map;
261+
});
262+
263+
await handler(mockEvent({ caseNumbers: mockCaseNumbers }), {} as any, {} as any);
264+
265+
expect(mockWorksheet.getRow).toHaveBeenCalledWith(2);
266+
const caseNumberCell = mockCells.get('2:1');
267+
expect(caseNumberCell?.value).toEqual({
268+
text: 'CASE123',
269+
hyperlink: 'https://portal.example.com/search-results/#/case-id-123',
270+
});
271+
expect(caseNumberCell?.font).toMatchObject({
272+
color: { argb: 'FF0563C1' },
273+
underline: true,
274+
});
275+
});
276+
277+
it('should keep text value and hyperlink relationship for quoted case numbers', async () => {
278+
const mockCaseNumbers = ['CASE"123'];
279+
280+
const mockSummary = { charges: [] };
281+
const mockZipCase = { caseId: 'case"id-123', fetchStatus: { status: 'complete' } };
282+
(BatchHelper.getMany as jest.Mock).mockImplementation(async (keys: any[]) => {
283+
const map = new Map();
284+
keys.forEach(key => {
285+
if (key.PK === 'CASE#CASE"123' && key.SK === 'SUMMARY') map.set(key, mockSummary);
286+
if (key.PK === 'CASE#CASE"123' && key.SK === 'ID') map.set(key, mockZipCase);
287+
});
288+
return map;
289+
});
290+
291+
await handler(mockEvent({ caseNumbers: mockCaseNumbers }), {} as any, {} as any);
292+
293+
const caseNumberCell = mockCells.get('2:1');
294+
expect(caseNumberCell?.value).toEqual({
295+
text: 'CASE"123',
296+
hyperlink: 'https://portal.example.com/search-results/#/case"id-123',
297+
});
298+
});
223299
});

serverless/app/handlers/export.ts

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { APIGatewayProxyHandler } from 'aws-lambda';
2-
import * as XLSX from 'xlsx';
2+
import ExcelJS from 'exceljs';
33
import { BatchHelper, Key } from '../../lib/StorageClient';
44
import { CaseSummary, Disposition, ZipCase } from '../../../shared/types';
55

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

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

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

68+
const caseUrl = zipCase.caseId && process.env.PORTAL_CASE_URL ? `${process.env.PORTAL_CASE_URL}/#/${zipCase.caseId}` : '';
69+
if (caseUrl) {
70+
caseNumberToUrlMap.set(caseNumber, caseUrl);
71+
}
72+
6773
// Handle failed cases and those without summaries
6874
if (!summary || zipCase.fetchStatus.status === 'failed') {
6975
rows.push({
@@ -124,29 +130,58 @@ export const handler: APIGatewayProxyHandler = async event => {
124130
}
125131

126132
// Create workbook and worksheet
127-
const wb = XLSX.utils.book_new();
128-
const ws = XLSX.utils.json_to_sheet(rows);
129-
130-
// Auto-fit columns
131-
if (rows.length > 0) {
132-
const headers = Object.keys(rows[0]);
133-
const colWidths = headers.map(key => {
134-
let maxLength = key.length;
135-
rows.forEach(row => {
136-
const val = row[key as keyof ExportRow];
137-
const len = val ? String(val).length : 0;
138-
if (len > maxLength) maxLength = len;
139-
});
140-
// Cap the width at 50 to prevent massive columns, but ensure at least 10
141-
return { wch: Math.min(Math.max(maxLength + 2, 10), 50) };
133+
const wb = new ExcelJS.Workbook();
134+
const ws = wb.addWorksheet('Cases');
135+
136+
const headers: (keyof ExportRow)[] = [
137+
'Case Number',
138+
'Court Name',
139+
'Arrest Date',
140+
'Offense Description',
141+
'Offense Level',
142+
'Offense Date',
143+
'Disposition',
144+
'Disposition Date',
145+
'Arresting Agency',
146+
'Notes',
147+
];
148+
149+
const colWidths = headers.map(key => {
150+
let maxLength = key.length;
151+
rows.forEach(row => {
152+
const val = row[key];
153+
const len = val ? String(val).length : 0;
154+
if (len > maxLength) maxLength = len;
155+
});
156+
return Math.min(Math.max(maxLength + 2, 10), 50);
157+
});
158+
159+
ws.columns = headers.map((header, idx) => ({
160+
header,
161+
key: header,
162+
width: colWidths[idx],
163+
}));
164+
ws.addRows(rows);
165+
166+
const caseNumberColumn = headers.indexOf('Case Number') + 1;
167+
if (caseNumberColumn > 0) {
168+
rows.forEach((row, idx) => {
169+
const caseNumber = row['Case Number'];
170+
const caseUrl = caseNumberToUrlMap.get(caseNumber);
171+
if (caseUrl) {
172+
const caseNumberCell = ws.getRow(idx + 2).getCell(caseNumberColumn);
173+
caseNumberCell.value = { text: caseNumber, hyperlink: caseUrl };
174+
caseNumberCell.font = {
175+
...(caseNumberCell.font || {}),
176+
color: { argb: 'FF0563C1' },
177+
underline: true,
178+
};
179+
}
142180
});
143-
ws['!cols'] = colWidths;
144181
}
145182

146-
XLSX.utils.book_append_sheet(wb, ws, 'Cases');
147-
148183
// Generate buffer
149-
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
184+
const buffer = Buffer.from(await wb.xlsx.writeBuffer());
150185

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

0 commit comments

Comments
 (0)