From 667bb5bca9f86678b9167aab03f2fecad21d716b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Oct 2025 18:16:51 +0000 Subject: [PATCH 1/4] #141 add copy button for case numbers in search results --- frontend/src/components/app/SearchResult.tsx | 40 ++++++++- .../app/__tests__/SearchResult.test.tsx | 84 ++++++++++++++++++- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/app/SearchResult.tsx b/frontend/src/components/app/SearchResult.tsx index 2eb001d..13c44da 100644 --- a/frontend/src/components/app/SearchResult.tsx +++ b/frontend/src/components/app/SearchResult.tsx @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { useState } from 'react'; import type { SearchResult as SearchResultType } from '../../../../shared/types'; import { parseDateString, formatDisplayDate } from '../../../../shared/DateTimeUtils'; import SearchStatus from './SearchStatus'; -import { ArrowTopRightOnSquareIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { ArrowTopRightOnSquareIcon, DocumentDuplicateIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { PORTAL_CASE_URL } from '../../aws-exports'; import { useRemoveCase } from '../../hooks/useCaseSearch'; import { Button as HeadlessButton } from '@headlessui/react'; @@ -13,6 +13,8 @@ interface SearchResultProps { const SearchResult: React.FC = ({ searchResult: sr }) => { const removeCase = useRemoveCase(); + const [copySuccess, setCopySuccess] = useState(false); + // Add a safety check to ensure we have a properly structured case object if (!sr?.zipCase?.caseNumber) { console.error('Invalid case object received by SearchResult:', sr); @@ -25,6 +27,20 @@ const SearchResult: React.FC = ({ searchResult: sr }) => { removeCase(c.caseNumber); }; + const copyCaseNumber = async () => { + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(c.caseNumber); + setCopySuccess(true); + setTimeout(() => { + setCopySuccess(false); + }, 2000); + } catch (error) { + console.error('Failed to copy case number:', error); + } + } + }; + return (
{/* Remove button - appears in upper right corner */} @@ -46,7 +62,7 @@ const SearchResult: React.FC = ({ searchResult: sr }) => {
{c.caseId ? ( -
+ ) : ( -
{c.caseNumber}
+
+ {c.caseNumber} + + + +
)}
diff --git a/frontend/src/components/app/__tests__/SearchResult.test.tsx b/frontend/src/components/app/__tests__/SearchResult.test.tsx index 4dd017d..68c050d 100644 --- a/frontend/src/components/app/__tests__/SearchResult.test.tsx +++ b/frontend/src/components/app/__tests__/SearchResult.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; import SearchResult from '../SearchResult'; import { SearchResult as SearchResultType, ZipCase } from '../../../../../shared/types'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -19,9 +20,7 @@ const createTestQueryClient = () => { const createWrapper = (queryClient?: QueryClient) => { const testQueryClient = queryClient || createTestQueryClient(); - return ({ children }: { children: React.ReactNode }) => ( - {children} - ); + return ({ children }: { children: React.ReactNode }) => {children}; }; // Mock SearchStatus component @@ -33,6 +32,11 @@ vi.mock('../SearchStatus', () => ({ )), })); +// Mock useCaseSearch hook +vi.mock('../../../hooks/useCaseSearch', () => ({ + useRemoveCase: () => vi.fn(), +})); + // Mock constants from aws-exports vi.mock('../../../aws-exports', () => ({ API_URL: 'https://api.example.com', @@ -325,4 +329,78 @@ describe('SearchResult component', () => { expect(removeButton).toBeInTheDocument(); expect(removeButton).toHaveAttribute('title', 'Remove case from results'); }); + + it('displays copy button when caseId is present', () => { + const testCase = createTestCase(); + render(, { wrapper: createWrapper() }); + + // Check that copy button is rendered + const copyButton = screen.getByTitle('Copy case number to clipboard'); + expect(copyButton).toBeInTheDocument(); + }); + + it('displays copy button when caseId is not present', () => { + const testCase = createTestCase({ + zipCase: { + caseNumber: '22CR123456-789', + caseId: undefined, + fetchStatus: { + status: 'processing', + }, + }, + }); + render(, { wrapper: createWrapper() }); + + // Check that copy button is rendered + const copyButton = screen.getByTitle('Copy case number to clipboard'); + expect(copyButton).toBeInTheDocument(); + }); + + it('copies case number to clipboard when copy button is clicked', async () => { + const user = userEvent.setup(); + + // Mock clipboard API + const writeTextMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: writeTextMock, + }, + writable: true, + configurable: true, + }); + + const testCase = createTestCase(); + render(, { wrapper: createWrapper() }); + + // Click the copy button + const copyButton = screen.getByTitle('Copy case number to clipboard'); + await user.click(copyButton); + + // Verify that clipboard.writeText was called with the correct case number + expect(writeTextMock).toHaveBeenCalledWith('22CR123456-789'); + }); + + it('shows visual feedback when case number is copied', async () => { + const user = userEvent.setup(); + + // Mock clipboard API + const writeTextMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: writeTextMock, + }, + writable: true, + configurable: true, + }); + + const testCase = createTestCase(); + render(, { wrapper: createWrapper() }); + + // Click the copy button + const copyButton = screen.getByTitle('Copy case number to clipboard'); + await user.click(copyButton); + + // Check that the title changes to indicate success + expect(screen.getByTitle('Copied!')).toBeInTheDocument(); + }); }); From 52cc45f5788215087e32d9839f5ac77a93c29b26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:22:08 +0000 Subject: [PATCH 2/4] #158 add copy-all button --- .../src/components/app/SearchResultsList.tsx | 60 +++++++++++++- .../app/__tests__/SearchResultsList.test.tsx | 81 +++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/app/SearchResultsList.tsx b/frontend/src/components/app/SearchResultsList.tsx index 7bdb22b..a4bf1b4 100644 --- a/frontend/src/components/app/SearchResultsList.tsx +++ b/frontend/src/components/app/SearchResultsList.tsx @@ -1,7 +1,8 @@ import SearchResult from './SearchResult'; import { useSearchResults, useConsolidatedPolling } from '../../hooks/useCaseSearch'; import { SearchResult as SearchResultType } from '../../../../shared/types'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState, useRef } from 'react'; +import { ClipboardDocumentIcon, CheckIcon } from '@heroicons/react/24/outline'; type DisplayItem = SearchResultType | 'divider'; @@ -11,6 +12,8 @@ function CaseResultItem({ searchResult }: { searchResult: SearchResultType }) { export default function SearchResultsList() { const { data, isLoading, isError, error } = useSearchResults(); + const [copied, setCopied] = useState(false); + const copiedTimeoutRef = useRef(null); // Extract batches and create a flat display list with dividers const displayItems = useMemo(() => { @@ -58,6 +61,34 @@ export default function SearchResultsList() { // Use the consolidated polling approach for all non-terminal cases const polling = useConsolidatedPolling(); + // Function to copy all case numbers to clipboard + const copyCaseNumbers = async () => { + if (!searchResults || searchResults.length === 0) { + return; + } + + // Extract case numbers, sort them alphanumerically, and join with newlines + const caseNumbers = searchResults + .map(result => result.zipCase.caseNumber) + .sort() + .join('\n'); + + try { + await navigator.clipboard.writeText(caseNumbers); + setCopied(true); + + // Clear any existing timeout + if (copiedTimeoutRef.current) { + clearTimeout(copiedTimeoutRef.current); + } + + // Reset copied state after 2 seconds + copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy case numbers:', err); + } + }; + // Start/stop polling based on whether we have non-terminal cases useEffect(() => { if (searchResults.length > 0) { @@ -80,6 +111,15 @@ export default function SearchResultsList() { }; }, [searchResults, polling]); + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (copiedTimeoutRef.current) { + clearTimeout(copiedTimeoutRef.current); + } + }; + }, []); + if (isError) { console.error('Error in useSearchResults:', error); } @@ -102,7 +142,23 @@ export default function SearchResultsList() { <> {displayItems.length > 0 ? (
-

Search Results

+
+

Search Results

+ +
{displayItems.map((item, index) => (
diff --git a/frontend/src/components/app/__tests__/SearchResultsList.test.tsx b/frontend/src/components/app/__tests__/SearchResultsList.test.tsx index eae8f9d..32d0877 100644 --- a/frontend/src/components/app/__tests__/SearchResultsList.test.tsx +++ b/frontend/src/components/app/__tests__/SearchResultsList.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import SearchResultsList from '../SearchResultsList'; import { SearchResult, ZipCase } from '../../../../../shared/types'; +import userEvent from '@testing-library/user-event'; // Import hooks to mock import * as hooks from '../../../hooks/useCaseSearch'; @@ -230,4 +231,84 @@ describe('SearchResultsList', () => { // Console error should have been called expect(console.error).toHaveBeenCalled(); }); + + it('renders copy case numbers button when results are present', () => { + const mockResults = { + case1: createSearchResult('case1', 'complete'), + case2: createSearchResult('case2', 'processing'), + }; + + vi.mocked(hooks.useSearchResults).mockReturnValue({ + data: { + results: mockResults, + searchBatches: [['case1', 'case2']], + }, + isLoading: false, + isError: false, + error: null, + } as any); + + render(, { wrapper: createWrapper() }); + + // Check that the copy button is rendered + expect(screen.getByRole('button', { name: /copy all case numbers/i })).toBeInTheDocument(); + }); + + it('does not render copy case numbers button when no results', () => { + vi.mocked(hooks.useSearchResults).mockReturnValue({ + data: { results: {}, searchBatches: [] }, + isLoading: false, + isError: false, + error: null, + } as any); + + render(, { wrapper: createWrapper() }); + + // Check that the copy button is not rendered + expect(screen.queryByRole('button', { name: /copy all case numbers/i })).not.toBeInTheDocument(); + }); + + it('copies case numbers to clipboard when button is clicked', async () => { + const user = userEvent.setup(); + + const mockResults = { + case2: createSearchResult('case2', 'processing'), + case3: createSearchResult('case3', 'queued'), + case1: createSearchResult('case1', 'complete'), + }; + + vi.mocked(hooks.useSearchResults).mockReturnValue({ + data: { + results: mockResults, + searchBatches: [['case2', 'case3', 'case1']], + }, + isLoading: false, + isError: false, + error: null, + } as any); + + // Mock clipboard API + const writeTextMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: writeTextMock, + }, + writable: true, + configurable: true, + }); + + render(, { wrapper: createWrapper() }); + + const copyButton = screen.getByRole('button', { name: /copy all case numbers/i }); + await user.click(copyButton); + + // Verify clipboard.writeText was called with sorted case numbers + expect(writeTextMock).toHaveBeenCalledWith('case1\ncase2\ncase3'); + + // Verify button still shows "Copy Case Numbers" text + expect(screen.getByText('Copy Case Numbers')).toBeInTheDocument(); + + // Verify button has green styling indicating success + expect(copyButton).toHaveClass('text-green-700'); + }); }); From 2727b865ffe75627a070997a8b5f1d78d8ef1c93 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 3/4] #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 6482c09f9489d20d0d5dbd09829490f66e610855 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 4/4] #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 */