From 8f12491042d54eff4bc104a5e336df5bbd517294 Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Sat, 13 Dec 2025 13:39:02 -0500 Subject: [PATCH 1/2] #129 add backend handler for xlsx export --- .../app/handlers/__tests__/export.test.ts | 223 ++++++++++++++++++ serverless/app/handlers/export.ts | 153 ++++++++++++ serverless/app/serverless.yml | 9 + serverless/lib/StorageClient.ts | 2 +- serverless/package-lock.json | 118 ++++++++- serverless/package.json | 3 +- 6 files changed, 493 insertions(+), 15 deletions(-) create mode 100644 serverless/app/handlers/__tests__/export.test.ts create mode 100644 serverless/app/handlers/export.ts diff --git a/serverless/app/handlers/__tests__/export.test.ts b/serverless/app/handlers/__tests__/export.test.ts new file mode 100644 index 0000000..1c2a3d7 --- /dev/null +++ b/serverless/app/handlers/__tests__/export.test.ts @@ -0,0 +1,223 @@ +import { handler } from '../export'; +import { BatchHelper, Key } from '../../../lib/StorageClient'; +import * as XLSX from 'xlsx'; + +// Mock dependencies +jest.mock('../../../lib/StorageClient', () => ({ + BatchHelper: { + getMany: jest.fn(), + }, + Key: { + Case: (caseNumber: string) => ({ + SUMMARY: { PK: `CASE#${caseNumber}`, SK: 'SUMMARY' }, + ID: { PK: `CASE#${caseNumber}`, SK: 'ID' }, + }), + }, +})); +jest.mock('xlsx', () => ({ + utils: { + book_new: jest.fn(), + json_to_sheet: jest.fn(), + book_append_sheet: jest.fn(), + }, + write: jest.fn().mockReturnValue(Buffer.from('mock-excel-content')), +})); + +describe('export handler', () => { + const mockEvent = (body: any) => + ({ + body: JSON.stringify(body), + }) as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return 400 if body is missing', async () => { + const result = await handler({} as any, {} as any, {} as any); + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ message: 'Missing request body' }), + }); + }); + + it('should return 400 if caseNumbers is invalid', async () => { + const result = await handler(mockEvent({ caseNumbers: [] }), {} as any, {} as any); + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ message: 'Invalid or empty caseNumbers array' }), + }); + }); + + it('should generate excel file with correct data', async () => { + const mockCaseNumbers = ['CASE123']; + + // Mock data + const mockSummary = { + court: 'Test Court', + arrestOrCitationDate: '2023-01-01', + filingAgency: 'Test Agency', + charges: [ + { + description: 'Test Charge', + degree: { code: 'F1', description: 'Felony 1' }, + offenseDate: '2023-01-01', + dispositions: [{ description: 'Guilty', date: '2023-02-01' }], + }, + ], + }; + + const mockZipCase = { + 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; + }); + + const result = await handler(mockEvent({ caseNumbers: mockCaseNumbers }), {} as any, {} as any); + + expect(result).toMatchObject({ + statusCode: 200, + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + isBase64Encoded: true, + }); + + // Verify XLSX calls + expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([ + { + 'Case Number': 'CASE123', + 'Court Name': 'Test Court', + 'Arrest Date': '2023-01-01', + 'Offense Description': 'Test Charge', + 'Offense Level': 'F1', // Raw code + 'Offense Date': '2023-01-01', + Disposition: 'Guilty', + 'Disposition Date': '2023-02-01', + 'Arresting Agency': 'Test Agency', + Notes: '', + }, + ]); + }); + + it('should handle failed cases', async () => { + const mockCaseNumbers = ['CASE_FAILED']; + + const mockZipCase = { + fetchStatus: { status: 'failed' }, + }; + + (BatchHelper.getMany as jest.Mock).mockImplementation(async (keys: any[]) => { + const map = new Map(); + keys.forEach(key => { + if (key.PK === 'CASE#CASE_FAILED' && key.SK === 'ID') map.set(key, mockZipCase); + }); + return map; + }); + + await handler(mockEvent({ caseNumbers: mockCaseNumbers }), {} as any, {} as any); + + expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([ + expect.objectContaining({ + 'Case Number': 'CASE_FAILED', + Notes: 'Failed to load case data', + }), + ]); + }); + + it('should handle cases with no charges', async () => { + const mockCaseNumbers = ['CASE_NO_CHARGES']; + + const mockSummary = { + court: 'Test Court', + charges: [], + }; + + const mockZipCase = { + fetchStatus: { status: 'complete' }, + }; + + (BatchHelper.getMany as jest.Mock).mockImplementation(async (keys: any[]) => { + const map = new Map(); + keys.forEach(key => { + if (key.PK === 'CASE#CASE_NO_CHARGES' && key.SK === 'SUMMARY') map.set(key, mockSummary); + if (key.PK === 'CASE#CASE_NO_CHARGES' && key.SK === 'ID') map.set(key, mockZipCase); + }); + return map; + }); + + await handler(mockEvent({ caseNumbers: mockCaseNumbers }), {} as any, {} as any); + + expect(XLSX.utils.json_to_sheet).toHaveBeenCalledWith([ + expect.objectContaining({ + 'Case Number': 'CASE_NO_CHARGES', + Notes: 'No charges found', + }), + ]); + }); + + it('should use raw offense level codes', async () => { + const mockCaseNumbers = ['CASE_LEVELS']; + + const mockSummary = { + charges: [ + { degree: { code: 'M1' }, dispositions: [] }, + { degree: { description: 'Felony Class A' }, dispositions: [] }, // No code + { degree: { code: 'GL M' }, dispositions: [] }, + { degree: { code: 'T' }, dispositions: [] }, + { degree: { code: 'INF' }, dispositions: [] }, + ], + }; + + const mockZipCase = { + fetchStatus: { status: 'complete' }, + }; + + (BatchHelper.getMany as jest.Mock).mockImplementation(async (keys: any[]) => { + const map = new Map(); + keys.forEach(key => { + if (key.PK === 'CASE#CASE_LEVELS' && key.SK === 'SUMMARY') map.set(key, mockSummary); + if (key.PK === 'CASE#CASE_LEVELS' && key.SK === 'ID') map.set(key, mockZipCase); + }); + return map; + }); + + await handler(mockEvent({ caseNumbers: mockCaseNumbers }), {} as any, {} as any); + + const calls = (XLSX.utils.json_to_sheet as jest.Mock).mock.calls[0][0]; + const levels = calls.map((row: any) => row['Offense Level']); + + expect(levels).toEqual(['M1', '', 'GL M', 'T', 'INF']); + }); + + it('should use correct filename format', async () => { + const mockCaseNumbers = ['CASE123']; + const mockSummary = { charges: [] }; + const mockZipCase = { 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; + }); + + const result = await handler(mockEvent({ caseNumbers: mockCaseNumbers }), {} as any, {} as any); + + expect(result).toMatchObject({ + statusCode: 200, + headers: { + 'Content-Disposition': expect.stringMatching(/attachment; filename="ZipCase-Export-\d{8}-\d{6}\.xlsx"/), + }, + }); + }); +}); diff --git a/serverless/app/handlers/export.ts b/serverless/app/handlers/export.ts new file mode 100644 index 0000000..1978e6c --- /dev/null +++ b/serverless/app/handlers/export.ts @@ -0,0 +1,153 @@ +import { APIGatewayProxyHandler } from 'aws-lambda'; +import * as XLSX from 'xlsx'; +import { BatchHelper, Key } from '../../lib/StorageClient'; +import { CaseSummary, Disposition, ZipCase } from '../../../shared/types'; + +interface ExportRequest { + caseNumbers: string[]; +} + +interface ExportRow { + 'Case Number': string; + 'Court Name': string; + 'Arrest Date': string; + 'Offense Description': string; + 'Offense Level': string; + 'Offense Date': string; + Disposition: string; + 'Disposition Date': string; + 'Arresting Agency': string; + Notes: string; +} + +export const handler: APIGatewayProxyHandler = async event => { + try { + if (!event.body) { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Missing request body' }), + }; + } + + const { caseNumbers } = JSON.parse(event.body) as ExportRequest; + + if (!caseNumbers || !Array.isArray(caseNumbers) || caseNumbers.length === 0) { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Invalid or empty caseNumbers array' }), + }; + } + + // Construct keys for batch get + const summaryKeys = caseNumbers.map(cn => Key.Case(cn).SUMMARY); + const idKeys = caseNumbers.map(cn => Key.Case(cn).ID); + const allKeys = [...summaryKeys, ...idKeys]; + + // Fetch case summaries and zip cases + const dataMap = await BatchHelper.getMany(allKeys); + + const rows: ExportRow[] = []; + + for (const caseNumber of caseNumbers) { + const summaryKey = Key.Case(caseNumber).SUMMARY; + const idKey = Key.Case(caseNumber).ID; + + // Find keys in the original list to ensure object reference match for map lookup + const originalSummaryKey = allKeys.find(k => k.PK === summaryKey.PK && k.SK === summaryKey.SK); + const originalIdKey = allKeys.find(k => k.PK === idKey.PK && k.SK === idKey.SK); + + const summary = originalSummaryKey ? (dataMap.get(originalSummaryKey) as CaseSummary) : undefined; + const zipCase = originalIdKey ? (dataMap.get(originalIdKey) as ZipCase) : undefined; + + // Filter out notFound cases + if (!zipCase || zipCase.fetchStatus.status === 'notFound') { + continue; + } + + // Handle failed cases and those without summaries + if (!summary || zipCase.fetchStatus.status === 'failed') { + rows.push({ + 'Case Number': caseNumber, + 'Court Name': '', + 'Arrest Date': '', + 'Offense Description': '', + 'Offense Level': '', + 'Offense Date': '', + 'Disposition': '', + 'Disposition Date': '', + 'Arresting Agency': '', + Notes: 'Failed to load case data', + }); + continue; + } + + if (!summary.charges || summary.charges.length === 0) { + rows.push({ + 'Case Number': caseNumber, + 'Court Name': summary.court || '', + 'Arrest Date': summary.arrestOrCitationDate || '', + 'Offense Description': '', + 'Offense Level': '', + 'Offense Date': '', + 'Disposition': '', + 'Disposition Date': '', + 'Arresting Agency': summary.filingAgency || '', + Notes: 'No charges found', + }); + continue; + } + + for (const charge of summary.charges) { + // Find the most relevant disposition (e.g., the latest one) + let disposition: Disposition | undefined; + if (charge.dispositions && charge.dispositions.length > 0) { + // Sort by date descending + const sortedDispositions = [...charge.dispositions].sort((a, b) => { + return new Date(b.date).getTime() - new Date(a.date).getTime(); + }); + disposition = sortedDispositions[0]; + } + + rows.push({ + 'Case Number': caseNumber, + 'Court Name': summary.court || '', + 'Arrest Date': summary.arrestOrCitationDate || '', + 'Offense Description': charge.description || '', + 'Offense Level': charge.degree?.code || '', + 'Offense Date': charge.offenseDate || '', + 'Disposition': disposition ? disposition.description : '', + 'Disposition Date': disposition ? disposition.date : '', + 'Arresting Agency': charge.filingAgency || summary.filingAgency || '', + Notes: '', + }); + } + } + + // Create workbook and worksheet + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.json_to_sheet(rows); + XLSX.utils.book_append_sheet(wb, ws, 'Cases'); + + // Generate buffer + const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); + + const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0]; + const filename = `ZipCase-Export-${timestamp}.xlsx`; + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + body: buffer.toString('base64'), + isBase64Encoded: true, + }; + } catch (error) { + console.error('Error exporting cases:', error); + return { + statusCode: 500, + body: JSON.stringify({ message: 'Internal server error' }), + }; + } +}; diff --git a/serverless/app/serverless.yml b/serverless/app/serverless.yml index 68d8c1b..f99fe5e 100644 --- a/serverless/app/serverless.yml +++ b/serverless/app/serverless.yml @@ -334,3 +334,12 @@ functions: - sqs: arn: ${cf:infra-${self:provider.stage}.CaseDataQueueArn} batchSize: 10 # Higher concurrency for case data processing + + exportCases: + handler: handlers/export.handler + memorySize: 1024 + events: + - httpApi: + path: /export + method: post + authorizer: cognitoAuth diff --git a/serverless/lib/StorageClient.ts b/serverless/lib/StorageClient.ts index 5b12a54..4d18f79 100644 --- a/serverless/lib/StorageClient.ts +++ b/serverless/lib/StorageClient.ts @@ -124,7 +124,7 @@ export const BatchHelper = { * @param keys Array of composite keys to get * @returns Map of composite keys to their corresponding items */ - async getMany>(keys: DynamoCompositeKey[]): Promise> { + async getMany(keys: DynamoCompositeKey[]): Promise> { if (keys.length === 0) { return new Map(); } diff --git a/serverless/package-lock.json b/serverless/package-lock.json index 9592dc0..f20f92b 100644 --- a/serverless/package-lock.json +++ b/serverless/package-lock.json @@ -17,7 +17,8 @@ "axios-cookiejar-support": "^5.0.5", "cheerio": "^1.0.0", "humanparser": "^2.7.0", - "tough-cookie": "^5.1.2" + "tough-cookie": "^5.1.2", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/js": "^9.24.0", @@ -9010,18 +9011,6 @@ "node": ">= 8" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -10170,6 +10159,15 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -10774,6 +10772,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", @@ -10886,6 +10897,15 @@ "node": ">= 0.12.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -10946,6 +10966,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -12079,6 +12111,15 @@ "node": ">=12.20.0" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fs-extra": { "version": "11.3.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", @@ -16174,6 +16215,18 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -16970,6 +17023,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -17133,6 +17204,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", diff --git a/serverless/package.json b/serverless/package.json index 8582f8a..9527762 100644 --- a/serverless/package.json +++ b/serverless/package.json @@ -29,7 +29,8 @@ "axios-cookiejar-support": "^5.0.5", "cheerio": "^1.0.0", "humanparser": "^2.7.0", - "tough-cookie": "^5.1.2" + "tough-cookie": "^5.1.2", + "xlsx": "^0.18.5" }, "scripts": { "test": "jest", From 3879b6ead3e7f2abe1869196f4b3421677ecce97 Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Sat, 13 Dec 2025 13:51:40 -0500 Subject: [PATCH 2/2] #129 add export function to frontend --- frontend/package-lock.json | 11 ++ frontend/package.json | 1 + .../src/components/app/SearchResultsList.tsx | 114 +++++++++++++++--- frontend/src/services/ZipCaseClient.ts | 71 +++++++++++ 4 files changed, 181 insertions(+), 16 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a0a0c23..3307b97 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@tanstack/react-query": "^5.69.0", "aws-amplify": "^6.14.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "framer-motion": "^12.4.7", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -7048,6 +7049,16 @@ "node": ">=18" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5593baa..9770072 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@tanstack/react-query": "^5.69.0", "aws-amplify": "^6.14.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "framer-motion": "^12.4.7", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/components/app/SearchResultsList.tsx b/frontend/src/components/app/SearchResultsList.tsx index a4bf1b4..1fb2b09 100644 --- a/frontend/src/components/app/SearchResultsList.tsx +++ b/frontend/src/components/app/SearchResultsList.tsx @@ -2,7 +2,8 @@ import SearchResult from './SearchResult'; import { useSearchResults, useConsolidatedPolling } from '../../hooks/useCaseSearch'; import { SearchResult as SearchResultType } from '../../../../shared/types'; import { useEffect, useMemo, useState, useRef } from 'react'; -import { ClipboardDocumentIcon, CheckIcon } from '@heroicons/react/24/outline'; +import { ArrowDownTrayIcon, CheckIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline'; +import { ZipCaseClient } from '../../services/ZipCaseClient'; type DisplayItem = SearchResultType | 'divider'; @@ -12,9 +13,12 @@ function CaseResultItem({ searchResult }: { searchResult: SearchResultType }) { export default function SearchResultsList() { const { data, isLoading, isError, error } = useSearchResults(); + const [copied, setCopied] = useState(false); const copiedTimeoutRef = useRef(null); + const [isExporting, setIsExporting] = useState(false); + // Extract batches and create a flat display list with dividers const displayItems = useMemo(() => { if (!data || !data.results || !data.searchBatches) { @@ -111,7 +115,7 @@ export default function SearchResultsList() { }; }, [searchResults, polling]); - // Cleanup timeout on unmount + // Clean up timeout on unmount useEffect(() => { return () => { if (copiedTimeoutRef.current) { @@ -120,6 +124,38 @@ export default function SearchResultsList() { }; }, []); + const handleExport = async () => { + const caseNumbers = searchResults.map(r => r.zipCase.caseNumber); + if (caseNumbers.length === 0) return; + + setIsExporting(true); + + // Set a timeout to reset the exporting state after 10 seconds + const timeoutId = setTimeout(() => { + setIsExporting(false); + }, 10000); + + try { + const client = new ZipCaseClient(); + await client.cases.export(caseNumbers); + } catch (error) { + console.error('Export failed:', error); + } finally { + clearTimeout(timeoutId); + setIsExporting(false); + } + }; + + const isExportEnabled = useMemo(() => { + if (searchResults.length === 0) return false; + const terminalStates = ['complete', 'failed', 'notFound']; + return searchResults.every(r => terminalStates.includes(r.zipCase.fetchStatus.status)); + }, [searchResults]); + + const exportableCount = useMemo(() => { + return searchResults.filter(r => r.zipCase.fetchStatus.status !== 'notFound').length; + }, [searchResults]); + if (isError) { console.error('Error in useSearchResults:', error); } @@ -144,20 +180,66 @@ export default function SearchResultsList() {

Search Results

- +
+ + +
{displayItems.map((item, index) => ( diff --git a/frontend/src/services/ZipCaseClient.ts b/frontend/src/services/ZipCaseClient.ts index d3961f9..9792f0f 100644 --- a/frontend/src/services/ZipCaseClient.ts +++ b/frontend/src/services/ZipCaseClient.ts @@ -1,4 +1,5 @@ import { fetchAuthSession } from '@aws-amplify/core'; +import { format } from 'date-fns'; import { API_URL } from '../aws-exports'; import { ApiKeyResponse, @@ -111,8 +112,78 @@ export class ZipCaseClient { get: async (caseNumber: string): Promise> => { return await this.request(`/case/${caseNumber}`, { method: 'GET' }); }, + + export: async (caseNumbers: string[]): Promise => { + return await this.download('/export', { + method: 'POST', + data: { caseNumbers }, + }); + }, }; + /** + * Helper method to handle file downloads + */ + private async download(endpoint: string, options: { method?: string; data?: unknown } = {}): Promise { + const { method = 'GET', data } = options; + const path = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint; + const url = `${this.baseUrl}/${path}`; + + try { + const session = await fetchAuthSession(); + const token = session.tokens?.accessToken; + + if (!token) { + throw new Error('No authentication token available'); + } + + const requestOptions: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token.toString()}`, + }, + }; + + if (method !== 'GET' && data) { + requestOptions.body = JSON.stringify(data); + } + + const response = await fetch(url, requestOptions); + + if (!response.ok) { + throw new Error(`Download failed with status ${response.status}`); + } + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + + const contentDisposition = response.headers.get('Content-Disposition'); + + // Generate a default filename with local timestamp + const timestamp = format(new Date(), 'yyyyMMdd-HHmmss'); + let filename = `ZipCase-Export-${timestamp}.xlsx`; + + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename="?([^"]+)"?/); + if (filenameMatch && filenameMatch.length === 2) { + filename = filenameMatch[1]; + } + } + + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(downloadUrl); + document.body.removeChild(a); + } catch (error) { + console.error('Download error:', error); + throw error; + } + } + /** * Core request method that handles all API interactions */