From 5d83da662f805568d20c671c0a6de4af3fff3e1f Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Sat, 4 Oct 2025 07:03:54 -0400 Subject: [PATCH 1/6] #128 get and include arrest/citation date in case summaries --- serverless/lib/CaseProcessor.ts | 63 ++++++++++- .../lib/__tests__/caseProcessor.test.ts | 105 ++++++++++++++++++ .../lib/__tests__/storageClient.test.ts | 23 ++++ shared/types/ZipCase.ts | 1 + 4 files changed, 187 insertions(+), 5 deletions(-) diff --git a/serverless/lib/CaseProcessor.ts b/serverless/lib/CaseProcessor.ts index 93e54cb..881a74b 100644 --- a/serverless/lib/CaseProcessor.ts +++ b/serverless/lib/CaseProcessor.ts @@ -502,8 +502,32 @@ const caseEndpoints: Record = { financialSummary: { path: "Service/FinancialSummary('{caseId}')", }, + caseEvents: { + path: "Service/CaseEvents('{caseId}')?top=200", + }, }; +function parseMMddyyyyToDate(dateStr: string): Date | null { + if (!dateStr || typeof dateStr !== 'string') { + return null; + } + + const parts = dateStr.split('/'); + if (parts.length !== 3) { + return null; + } + + const month = parseInt(parts[0], 10); + const day = parseInt(parts[1], 10); + const year = parseInt(parts[2], 10); + + if (Number.isNaN(month) || Number.isNaN(day) || Number.isNaN(year)) { + return null; + } + + return new Date(Date.UTC(year, month - 1, day)); +} + async function fetchCaseSummary(caseId: string): Promise { try { const portalCaseUrl = process.env.PORTAL_CASE_URL; @@ -578,8 +602,8 @@ async function fetchCaseSummary(caseId: string): Promise { // Wait for all promises to resolve const results = await Promise.all(endpointPromises); - // Check if any endpoint failed - const requiredFailure = results.find(result => !result.success); + // Treat caseEvents as optional; if any other endpoint failed, consider it a required failure + const requiredFailure = results.find(result => !result.success && result.key !== 'caseEvents'); if (requiredFailure) { console.error(`Required endpoint ${requiredFailure.key} failed: ${requiredFailure.error}`); @@ -651,10 +675,10 @@ function buildCaseSummary(rawData: Record): CaseSumma }); // Process dispositions and link them to charges - const events = rawData['dispositionEvents']['Events'] || []; - console.log(`📋 Found ${events.length} disposition events`); + const dispositionEvents = rawData['dispositionEvents']['Events'] || []; + console.log(`📋 Found ${dispositionEvents.length} disposition events`); - events + dispositionEvents .filter( // eslint-disable-next-line @typescript-eslint/no-explicit-any (eventData: any) => eventData && eventData['Type'] === 'CriminalDispositionEvent' @@ -721,6 +745,34 @@ function buildCaseSummary(rawData: Record): CaseSumma }); }); + // Process case-level events to determine arrest or citation date (LPSD events) + try { + const caseEvents = rawData['caseEvents']?.['Events'] || []; + console.log(`📋 Found ${caseEvents.length} case events`); + + // Filter only events that have the LPSD TypeId and a valid EventDate + const lpsdEvents = caseEvents.filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ev: any) => + ev && ev['Event'] && ev['Event']['TypeId'] && ev['Event']['TypeId']['Word'] === 'LPSD' && ev['Event']['EventDate'] + ); + + if (lpsdEvents.length > 0) { + // Parse dates and find the earliest + const parsedDates: Date[] = lpsdEvents + .map((ev: any) => parseMMddyyyyToDate(ev['Event']['EventDate'])) + .filter((d: Date | null): d is Date => d !== null); + + if (parsedDates.length > 0) { + const earliest = parsedDates.reduce((min, d) => (d.getTime() < min.getTime() ? d : min), parsedDates[0]); + caseSummary.arrestOrCitationDate = earliest.toISOString(); + console.log(`🔔 Set arrestOrCitationDate to ${caseSummary.arrestOrCitationDate}`); + } + } + } catch (evtErr) { + console.error('Error processing caseEvents for arrest/citation date:', evtErr); + } + return caseSummary; } catch (error) { AlertService.logError(Severity.ERROR, AlertCategory.SYSTEM, 'Error building case summary from raw data', error as Error, { @@ -736,6 +788,7 @@ const CaseProcessor = { processCaseData, queueCasesForSearch, fetchCaseIdFromPortal, + buildCaseSummary, }; export default CaseProcessor; diff --git a/serverless/lib/__tests__/caseProcessor.test.ts b/serverless/lib/__tests__/caseProcessor.test.ts index 760b67f..0ae5f31 100644 --- a/serverless/lib/__tests__/caseProcessor.test.ts +++ b/serverless/lib/__tests__/caseProcessor.test.ts @@ -140,4 +140,109 @@ describe('CaseProcessor', () => { expect(QueueClient.queueCasesForSearch).toHaveBeenCalledWith(cases, userId); }); }); + + // Tests for buildCaseSummary (moved from separate test file) + describe('buildCaseSummary', () => { + const { buildCaseSummary } = CaseProcessor as any; + + it('extracts the earliest LPSD Event.EventDate and sets arrestOrCitationDate as ISO string', () => { + const rawData = { + summary: { + CaseSummaryHeader: { + Style: 'State vs. Someone', + Heading: 'Circuit Court', + CaseId: 'case-123', + }, + }, + charges: { + Charges: [ + { + ChargeId: 1, + OffenseDate: '2020-01-01', + FiledDate: '2020-01-02', + ChargeOffense: { + ChargeOffenseDescription: 'Theft', + Statute: '123', + Degree: 'M', + DegreeDescription: 'Misdemeanor', + FineAmount: 0, + }, + }, + ], + }, + dispositionEvents: { + Events: [], + }, + caseEvents: { + Events: [ + { Event: { TypeId: { Word: 'LPSD' }, EventDate: '03/15/2021' } }, + { Event: { TypeId: { Word: 'LPSD' }, EventDate: '02/10/2021' } }, + { Event: { TypeId: { Word: 'OTHER' }, EventDate: '01/01/2020' } }, + ], + }, + }; + + const summary = buildCaseSummary(rawData); + + expect(summary).not.toBeNull(); + expect(summary?.arrestOrCitationDate).toBeDefined(); + + // Expected earliest LPSD date is 02/10/2021 -> construct UTC Date and compare ISO + const expectedIso = new Date(Date.UTC(2021, 1, 10)).toISOString(); + expect(summary?.arrestOrCitationDate).toBe(expectedIso); + }); + + it('does not set arrestOrCitationDate when no LPSD events present', () => { + const rawData = { + summary: { + CaseSummaryHeader: { + Style: 'State vs. Someone', + Heading: 'Circuit Court', + CaseId: 'case-456', + }, + }, + charges: { + Charges: [], + }, + dispositionEvents: { + Events: [], + }, + caseEvents: { + Events: [{ Event: { TypeId: { Word: 'OTHER' }, EventDate: '03/15/2021' } }], + }, + }; + + const summary = buildCaseSummary(rawData); + + expect(summary).not.toBeNull(); + expect(summary?.arrestOrCitationDate).toBeUndefined(); + }); + + it('ignores malformed LPSD Event.EventDate values', () => { + const { buildCaseSummary } = CaseProcessor as any; + + const rawData = { + summary: { + CaseSummaryHeader: { + Style: 'State vs. Someone', + Heading: 'Circuit Court', + CaseId: 'case-789', + }, + }, + charges: { Charges: [] }, + dispositionEvents: { Events: [] }, + caseEvents: { + Events: [ + { Event: { TypeId: { Word: 'LPSD' }, EventDate: 'not-a-date' } }, + { Event: { TypeId: { Word: 'LPSD' }, EventDate: '' } }, + { Event: { TypeId: { Word: 'LPSD' }, EventDate: null } }, + ], + }, + }; + + const summary = buildCaseSummary(rawData); + expect(summary).not.toBeNull(); + expect(summary?.arrestOrCitationDate).toBeUndefined(); + }); + }); }); diff --git a/serverless/lib/__tests__/storageClient.test.ts b/serverless/lib/__tests__/storageClient.test.ts index 1a9f265..fb399eb 100644 --- a/serverless/lib/__tests__/storageClient.test.ts +++ b/serverless/lib/__tests__/storageClient.test.ts @@ -421,5 +421,28 @@ describe('StorageClient.getSearchResults resilience', () => { // Verify cleanup was triggered for the corrupted case only expect(mockSetImmediate).toHaveBeenCalled(); }); + + it('should preserve arrestOrCitationDate when present in summary', async () => { + const { validateAndProcessCaseSummary } = require('../StorageClient'); + + const caseNumber = 'ARRESTDATE001'; + const caseData = { + caseNumber, + caseId: 'arrest-case-id', + fetchStatus: { status: 'complete' }, + lastUpdated: '2025-09-19T12:00:00Z', + }; + + const validSummaryItem = { + caseName: 'State vs Arrested', + court: 'Test Court', + charges: [], + arrestOrCitationDate: '2021-02-10T00:00:00.000Z', + }; + + const result = await validateAndProcessCaseSummary(caseNumber, caseData, validSummaryItem); + + expect(result).toEqual(validSummaryItem); + }); }); }); diff --git a/shared/types/ZipCase.ts b/shared/types/ZipCase.ts index 4b52fa9..8f5d156 100644 --- a/shared/types/ZipCase.ts +++ b/shared/types/ZipCase.ts @@ -32,6 +32,7 @@ export interface CaseSummary { caseName: string; court: string; charges: Charge[]; + arrestOrCitationDate?: string; } export interface ZipCase { From 1867b7c79b733d47b7cd60cbb2174b9ffe732a56 Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Sat, 4 Oct 2025 07:21:32 -0400 Subject: [PATCH 2/6] #128 add arrest/citation date to search results UI --- frontend/src/components/app/SearchResult.tsx | 12 +++++ .../app/__tests__/SearchResult.test.tsx | 48 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/frontend/src/components/app/SearchResult.tsx b/frontend/src/components/app/SearchResult.tsx index 5001ced..a0c6357 100644 --- a/frontend/src/components/app/SearchResult.tsx +++ b/frontend/src/components/app/SearchResult.tsx @@ -64,6 +64,18 @@ const SearchResult: React.FC = ({ searchResult: sr }) => {

{summary.caseName}

{summary.court}

+ {summary.arrestOrCitationDate && + (() => { + const d = new Date(summary.arrestOrCitationDate); + if (!isNaN(d.getTime())) { + return ( +

+ Arrest/Citation Date: {d.toLocaleDateString()} +

+ ); + } + return null; + })()}
{summary.charges && summary.charges.length > 0 && ( diff --git a/frontend/src/components/app/__tests__/SearchResult.test.tsx b/frontend/src/components/app/__tests__/SearchResult.test.tsx index 1ac0285..d39602d 100644 --- a/frontend/src/components/app/__tests__/SearchResult.test.tsx +++ b/frontend/src/components/app/__tests__/SearchResult.test.tsx @@ -169,4 +169,52 @@ describe('SearchResult component', () => { const errorMessage = screen.getByText('Error: Failed to fetch case data'); expect(errorMessage).toHaveClass('text-sm', 'text-red-600'); }); + + it('displays arrest/citation date when present', () => { + const testCase = createTestCase({ + caseSummary: { + caseName: 'State vs. Doe', + court: 'Circuit Court', + arrestOrCitationDate: '2022-02-15T00:00:00Z', + charges: [], + }, + }); + + render(); + + // Label should be present + expect(screen.getByText(/Arrest\/Citation Date:/)).toBeInTheDocument(); + + // The displayed date should contain the year 2022 (locale independent check) + expect(screen.getByText(/2022/)).toBeInTheDocument(); + }); + + it('handles malformed charge dates gracefully', () => { + const testCase = createTestCase({ + caseSummary: { + caseName: 'State vs. Doe', + court: 'Circuit Court', + arrestOrCitationDate: 'not-a-date', + charges: [ + { + offenseDate: 'also-not-a-date', + filedDate: 'not-a-date', + description: 'Weird Charge', + statute: '000', + degree: { code: 'X', description: 'Unknown' }, + fine: 0, + dispositions: [{ date: 'bad-date', code: 'UNK', description: 'Disposition ' }], + }, + ], + }, + }); + + render(); + + // Should not render 'Invalid Date' anywhere + expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument(); + + // Should still render the charge description + expect(screen.getByText('Weird Charge')).toBeInTheDocument(); + }); }); From 4dd324b8f4f2d1155bca3528e8c37d76c9fed78e Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Sat, 4 Oct 2025 07:22:33 -0400 Subject: [PATCH 3/6] #128 make date construction robust in case of malformed data --- frontend/src/components/app/SearchResult.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/app/SearchResult.tsx b/frontend/src/components/app/SearchResult.tsx index a0c6357..154b2cf 100644 --- a/frontend/src/components/app/SearchResult.tsx +++ b/frontend/src/components/app/SearchResult.tsx @@ -88,11 +88,17 @@ const SearchResult: React.FC = ({ searchResult: sr }) => {
Filed:{' '} - {new Date(charge.filedDate).toLocaleDateString()} + {(() => { + const d = new Date(charge.filedDate); + return isNaN(d.getTime()) ? '' : d.toLocaleDateString(); + })()}
Offense:{' '} - {new Date(charge.offenseDate).toLocaleDateString()} + {(() => { + const d = new Date(charge.offenseDate); + return isNaN(d.getTime()) ? '' : d.toLocaleDateString(); + })()}
Statute: {charge.statute} @@ -110,7 +116,11 @@ const SearchResult: React.FC = ({ searchResult: sr }) => {
Disposition:{' '} {charge.dispositions[0].description} ( - {new Date(charge.dispositions[0].date).toLocaleDateString()}) + {(() => { + const d = new Date(charge.dispositions[0].date); + return isNaN(d.getTime()) ? '' : d.toLocaleDateString(); + })()} + )
)}
From b65e3b3f949fa8bb976c581b0d465ba4670c0f9a Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Sun, 5 Oct 2025 07:37:41 -0400 Subject: [PATCH 4/6] #128 differentiate arrest and citation dates in case summaries --- frontend/src/components/app/SearchResult.tsx | 9 ++- .../app/__tests__/SearchResult.test.tsx | 5 +- serverless/lib/CaseProcessor.ts | 56 ++++++++++++++----- .../lib/__tests__/caseProcessor.test.ts | 36 +++++++++++- .../lib/__tests__/storageClient.test.ts | 3 +- shared/types/ZipCase.ts | 3 + 6 files changed, 91 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/app/SearchResult.tsx b/frontend/src/components/app/SearchResult.tsx index 154b2cf..420461c 100644 --- a/frontend/src/components/app/SearchResult.tsx +++ b/frontend/src/components/app/SearchResult.tsx @@ -68,9 +68,16 @@ const SearchResult: React.FC = ({ searchResult: sr }) => { (() => { const d = new Date(summary.arrestOrCitationDate); if (!isNaN(d.getTime())) { + const label = + summary.arrestOrCitationType === 'Arrest' + ? 'Arrest Date:' + : summary.arrestOrCitationType === 'Citation' + ? 'Citation Date:' + : 'Arrest/Citation Date:'; + return (

- Arrest/Citation Date: {d.toLocaleDateString()} + {label} {d.toLocaleDateString()}

); } diff --git a/frontend/src/components/app/__tests__/SearchResult.test.tsx b/frontend/src/components/app/__tests__/SearchResult.test.tsx index d39602d..086bcc7 100644 --- a/frontend/src/components/app/__tests__/SearchResult.test.tsx +++ b/frontend/src/components/app/__tests__/SearchResult.test.tsx @@ -176,14 +176,15 @@ describe('SearchResult component', () => { caseName: 'State vs. Doe', court: 'Circuit Court', arrestOrCitationDate: '2022-02-15T00:00:00Z', + arrestOrCitationType: 'Arrest', charges: [], }, }); render(); - // Label should be present - expect(screen.getByText(/Arrest\/Citation Date:/)).toBeInTheDocument(); + // Label should be present and explicitly show 'Arrest Date' + expect(screen.getByText(/Arrest Date:/)).toBeInTheDocument(); // The displayed date should contain the year 2022 (locale independent check) expect(screen.getByText(/2022/)).toBeInTheDocument(); diff --git a/serverless/lib/CaseProcessor.ts b/serverless/lib/CaseProcessor.ts index 881a74b..88ab2c4 100644 --- a/serverless/lib/CaseProcessor.ts +++ b/serverless/lib/CaseProcessor.ts @@ -745,28 +745,54 @@ function buildCaseSummary(rawData: Record): CaseSumma }); }); - // Process case-level events to determine arrest or citation date (LPSD events) + // Process case-level events to determine arrest or citation date (LPSD -> Arrest, CIT -> Citation) try { const caseEvents = rawData['caseEvents']?.['Events'] || []; console.log(`📋 Found ${caseEvents.length} case events`); - // Filter only events that have the LPSD TypeId and a valid EventDate - const lpsdEvents = caseEvents.filter( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (ev: any) => - ev && ev['Event'] && ev['Event']['TypeId'] && ev['Event']['TypeId']['Word'] === 'LPSD' && ev['Event']['EventDate'] + // Filter only events that have the LPSD (arrest) or CIT (citation) TypeId and a valid EventDate + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const candidateEvents = caseEvents.filter( + (ev: any) => ev && ev['Event'] && ev['Event']['TypeId'] && ev['Event']['TypeId']['Word'] && ev['Event']['EventDate'] ); - if (lpsdEvents.length > 0) { - // Parse dates and find the earliest - const parsedDates: Date[] = lpsdEvents - .map((ev: any) => parseMMddyyyyToDate(ev['Event']['EventDate'])) - .filter((d: Date | null): d is Date => d !== null); + console.log(`🔎 Found ${candidateEvents.length} candidate events for arrest/citation`); + + if (candidateEvents.length > 0) { + const parsedCandidates: { date: Date; type: 'Arrest' | 'Citation'; raw: string }[] = []; + + candidateEvents.forEach((ev: any, idx: number) => { + const typeWord = ev['Event']['TypeId']['Word']; + const eventDateStr = ev['Event']['EventDate']; + + if (typeWord !== 'LPSD' && typeWord !== 'CIT') { + return; + } + + const parsed = parseMMddyyyyToDate(eventDateStr); + if (parsed) { + parsedCandidates.push({ + date: parsed, + type: typeWord === 'LPSD' ? 'Arrest' : 'Citation', + raw: eventDateStr, + }); + console.log(` ✔ Candidate #${idx}: Type=${typeWord}, Parsed=${parsed.toISOString()}`); + } else { + console.warn(` ✖ Candidate #${idx} has unparseable date: ${eventDateStr}`); + } + }); - if (parsedDates.length > 0) { - const earliest = parsedDates.reduce((min, d) => (d.getTime() < min.getTime() ? d : min), parsedDates[0]); - caseSummary.arrestOrCitationDate = earliest.toISOString(); - console.log(`🔔 Set arrestOrCitationDate to ${caseSummary.arrestOrCitationDate}`); + if (parsedCandidates.length > 0) { + // Choose the earliest date among all matching candidates + const earliest = parsedCandidates.reduce( + (min, c) => (c.date.getTime() < min.date.getTime() ? c : min), + parsedCandidates[0] + ); + caseSummary.arrestOrCitationDate = earliest.date.toISOString(); + caseSummary.arrestOrCitationType = earliest.type; + console.log(`🔔 Set ${earliest.type} date to ${caseSummary.arrestOrCitationDate}`); + } else { + console.log('No parsable arrest/citation dates found among candidates'); } } } catch (evtErr) { diff --git a/serverless/lib/__tests__/caseProcessor.test.ts b/serverless/lib/__tests__/caseProcessor.test.ts index 0ae5f31..e71a660 100644 --- a/serverless/lib/__tests__/caseProcessor.test.ts +++ b/serverless/lib/__tests__/caseProcessor.test.ts @@ -145,7 +145,7 @@ describe('CaseProcessor', () => { describe('buildCaseSummary', () => { const { buildCaseSummary } = CaseProcessor as any; - it('extracts the earliest LPSD Event.EventDate and sets arrestOrCitationDate as ISO string', () => { + it('extracts the earliest LPSD Event.EventDate and sets arrestOrCitationDate and type as Arrest', () => { const rawData = { summary: { CaseSummaryHeader: { @@ -186,13 +186,43 @@ describe('CaseProcessor', () => { expect(summary).not.toBeNull(); expect(summary?.arrestOrCitationDate).toBeDefined(); + expect(summary?.arrestOrCitationType).toBe('Arrest'); // Expected earliest LPSD date is 02/10/2021 -> construct UTC Date and compare ISO const expectedIso = new Date(Date.UTC(2021, 1, 10)).toISOString(); expect(summary?.arrestOrCitationDate).toBe(expectedIso); }); - it('does not set arrestOrCitationDate when no LPSD events present', () => { + it('selects CIT over LPSD if earlier (sets type Citation)', () => { + const rawData = { + summary: { + CaseSummaryHeader: { + Style: 'State vs. Someone', + Heading: 'Circuit Court', + CaseId: 'case-234', + }, + }, + charges: { Charges: [] }, + dispositionEvents: { Events: [] }, + caseEvents: { + Events: [ + { Event: { TypeId: { Word: 'LPSD' }, EventDate: '03/15/2021' } }, + { Event: { TypeId: { Word: 'CIT' }, EventDate: '02/09/2021' } }, + ], + }, + }; + + const summary = buildCaseSummary(rawData); + + expect(summary).not.toBeNull(); + expect(summary?.arrestOrCitationDate).toBeDefined(); + expect(summary?.arrestOrCitationType).toBe('Citation'); + + const expectedIso = new Date(Date.UTC(2021, 1, 9)).toISOString(); + expect(summary?.arrestOrCitationDate).toBe(expectedIso); + }); + + it('does not set arrestOrCitationDate when no LPSD/CIT events present', () => { const rawData = { summary: { CaseSummaryHeader: { @@ -216,6 +246,7 @@ describe('CaseProcessor', () => { expect(summary).not.toBeNull(); expect(summary?.arrestOrCitationDate).toBeUndefined(); + expect(summary?.arrestOrCitationType).toBeUndefined(); }); it('ignores malformed LPSD Event.EventDate values', () => { @@ -243,6 +274,7 @@ describe('CaseProcessor', () => { const summary = buildCaseSummary(rawData); expect(summary).not.toBeNull(); expect(summary?.arrestOrCitationDate).toBeUndefined(); + expect(summary?.arrestOrCitationType).toBeUndefined(); }); }); }); diff --git a/serverless/lib/__tests__/storageClient.test.ts b/serverless/lib/__tests__/storageClient.test.ts index fb399eb..556f992 100644 --- a/serverless/lib/__tests__/storageClient.test.ts +++ b/serverless/lib/__tests__/storageClient.test.ts @@ -422,7 +422,7 @@ describe('StorageClient.getSearchResults resilience', () => { expect(mockSetImmediate).toHaveBeenCalled(); }); - it('should preserve arrestOrCitationDate when present in summary', async () => { + it('should preserve arrestOrCitationDate and type when present in summary', async () => { const { validateAndProcessCaseSummary } = require('../StorageClient'); const caseNumber = 'ARRESTDATE001'; @@ -438,6 +438,7 @@ describe('StorageClient.getSearchResults resilience', () => { court: 'Test Court', charges: [], arrestOrCitationDate: '2021-02-10T00:00:00.000Z', + arrestOrCitationType: 'Arrest', }; const result = await validateAndProcessCaseSummary(caseNumber, caseData, validSummaryItem); diff --git a/shared/types/ZipCase.ts b/shared/types/ZipCase.ts index 8f5d156..1927a45 100644 --- a/shared/types/ZipCase.ts +++ b/shared/types/ZipCase.ts @@ -28,11 +28,14 @@ export interface Charge { dispositions: Disposition[]; } +export type ArrestOrCitationType = 'Arrest' | 'Citation'; + export interface CaseSummary { caseName: string; court: string; charges: Charge[]; arrestOrCitationDate?: string; + arrestOrCitationType?: ArrestOrCitationType; } export interface ZipCase { From 773ba79f8031e4ec901e2a2f4f63041a8d3296f8 Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Mon, 6 Oct 2025 06:44:40 -0400 Subject: [PATCH 5/6] #128 formatting --- frontend/src/components/app/SearchResult.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/app/SearchResult.tsx b/frontend/src/components/app/SearchResult.tsx index 420461c..1b06327 100644 --- a/frontend/src/components/app/SearchResult.tsx +++ b/frontend/src/components/app/SearchResult.tsx @@ -72,8 +72,8 @@ const SearchResult: React.FC = ({ searchResult: sr }) => { summary.arrestOrCitationType === 'Arrest' ? 'Arrest Date:' : summary.arrestOrCitationType === 'Citation' - ? 'Citation Date:' - : 'Arrest/Citation Date:'; + ? 'Citation Date:' + : 'Arrest/Citation Date:'; return (

From db679b39ea57f5b0baed1b7faf69e4fa982d0619 Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Mon, 6 Oct 2025 07:26:52 -0400 Subject: [PATCH 6/6] #131 get and include filing agency in case summaries; capture but don't show addresses --- frontend/src/components/app/SearchResult.tsx | 14 ++ .../app/__tests__/SearchResult.test.tsx | 76 ++++++ serverless/lib/CaseProcessor.ts | 30 +++ .../lib/__tests__/CaseSearchProcessor.test.ts | 2 + .../lib/__tests__/caseProcessor.test.ts | 220 ++++++++++++++++++ .../lib/__tests__/storageClient.test.ts | 32 ++- shared/types/ZipCase.ts | 3 + 7 files changed, 369 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/app/SearchResult.tsx b/frontend/src/components/app/SearchResult.tsx index 1b06327..7e6b83b 100644 --- a/frontend/src/components/app/SearchResult.tsx +++ b/frontend/src/components/app/SearchResult.tsx @@ -83,6 +83,13 @@ const SearchResult: React.FC = ({ searchResult: sr }) => { } return null; })()} + + {/* Filing agency: shown at top-level if the case summary has a single filing agency for all charges */} + {summary.filingAgency && ( +

+ Filing Agency: {summary.filingAgency} +

+ )}
{summary.charges && summary.charges.length > 0 && ( @@ -118,6 +125,13 @@ const SearchResult: React.FC = ({ searchResult: sr }) => { Fine: ${charge.fine.toFixed(2)} )} + + {/* Per-charge filing agency: only shown when no top-level filing agency is present */} + {!summary.filingAgency && charge.filingAgency && ( +
+ Filing Agency: {charge.filingAgency} +
+ )} {charge.dispositions && charge.dispositions.length > 0 && (
diff --git a/frontend/src/components/app/__tests__/SearchResult.test.tsx b/frontend/src/components/app/__tests__/SearchResult.test.tsx index 086bcc7..907ee93 100644 --- a/frontend/src/components/app/__tests__/SearchResult.test.tsx +++ b/frontend/src/components/app/__tests__/SearchResult.test.tsx @@ -50,8 +50,11 @@ const createTestCase = (override = {}): SearchResultType => ({ description: 'Guilty', }, ], + filingAgency: null, + filingAgencyAddress: [], }, ], + filingAgency: null, }, ...override, }); @@ -218,4 +221,77 @@ describe('SearchResult component', () => { // Should still render the charge description expect(screen.getByText('Weird Charge')).toBeInTheDocument(); }); + + it('displays top-level filing agency when present', () => { + const testCase = createTestCase({ + caseSummary: { + caseName: 'State vs. Doe', + court: 'Circuit Court', + filingAgency: 'Metro PD', + charges: [ + { + offenseDate: '2022-01-01', + filedDate: '2022-01-02', + description: 'Theft', + statute: '123.456', + degree: { code: 'M', description: 'Misdemeanor' }, + fine: 0, + dispositions: [], + filingAgency: 'Metro PD', + }, + ], + }, + }); + + render(); + + // Top-level Filing Agency should be present + expect(screen.getByText(/Filing Agency:/)).toBeInTheDocument(); + expect(screen.getByText('Metro PD')).toBeInTheDocument(); + + // Per-charge filing agency should not be duplicated when top-level present + const chargeAgency = screen.queryAllByText(/Filing Agency:/).length; + expect(chargeAgency).toBe(1); // only the top-level label + }); + + it('displays per-charge filing agencies when they differ and no top-level is set', () => { + const testCase = createTestCase({ + caseSummary: { + caseName: 'State vs. Doe', + court: 'Circuit Court', + charges: [ + { + offenseDate: '2022-01-01', + filedDate: '2022-01-02', + description: 'Charge A', + statute: '111', + degree: { code: 'M', description: 'M' }, + fine: 0, + dispositions: [], + filingAgency: 'Dept A', + }, + { + offenseDate: '2022-02-01', + filedDate: '2022-02-02', + description: 'Charge B', + statute: '222', + degree: { code: 'M', description: 'M' }, + fine: 0, + dispositions: [], + filingAgency: 'Dept B', + }, + ], + }, + }); + + render(); + + // No single top-level filing agency — expect per-charge Filing Agency labels for each charge + const filingLabels = screen.queryAllByText(/Filing Agency:/); + expect(filingLabels.length).toBeGreaterThanOrEqual(2); + + // Both per-charge filing agencies should be present + expect(screen.getByText('Dept A')).toBeInTheDocument(); + expect(screen.getByText('Dept B')).toBeInTheDocument(); + }); }); diff --git a/serverless/lib/CaseProcessor.ts b/serverless/lib/CaseProcessor.ts index 88ab2c4..c16c556 100644 --- a/serverless/lib/CaseProcessor.ts +++ b/serverless/lib/CaseProcessor.ts @@ -639,6 +639,7 @@ function buildCaseSummary(rawData: Record): CaseSumma caseName: rawData['summary']['CaseSummaryHeader']['Style'] || '', court: rawData['summary']['CaseSummaryHeader']['Heading'] || '', charges: [], + filingAgency: null, }; const chargeMap = new Map(); @@ -663,8 +664,21 @@ function buildCaseSummary(rawData: Record): CaseSumma }, fine: typeof chargeOffense['FineAmount'] === 'number' ? chargeOffense['FineAmount'] : 0, dispositions: [], + filingAgency: null, + filingAgencyAddress: [], }; + const filingAgencyRaw = chargeData['FilingAgencyDescription']; + if (filingAgencyRaw) { + charge.filingAgency = String(filingAgencyRaw).trim(); + } + + // Extract filing agency address if present. It will be an array of strings. + const filingAgencyAddressRaw = chargeData['FilingAgencyAddress']; + if (filingAgencyAddressRaw) { + charge.filingAgencyAddress.push(...(filingAgencyAddressRaw as any)); + } + // Add to charges array caseSummary.charges.push(charge); @@ -674,6 +688,22 @@ function buildCaseSummary(rawData: Record): CaseSumma } }); + // After processing charges, derive top-level filing agency if appropriate + try { + const definedAgencies = caseSummary.charges.map(ch => ch.filingAgency).filter((a): a is string => a !== null && a.length > 0); + + const uniqueAgencies = Array.from(new Set(definedAgencies)); + + // If there's at least one defined agency, and all defined agencies are identical, + // set it on the case summary. Charges that lack an agency (null) are ignored for this decision. + if (uniqueAgencies.length === 1 && uniqueAgencies[0]) { + caseSummary.filingAgency = uniqueAgencies[0]; + console.log(`🔔 Set Filing Agency to ${caseSummary.filingAgency}`); + } + } catch (faErr) { + console.error('Error computing top-level filing agency:', faErr); + } + // Process dispositions and link them to charges const dispositionEvents = rawData['dispositionEvents']['Events'] || []; console.log(`📋 Found ${dispositionEvents.length} disposition events`); diff --git a/serverless/lib/__tests__/CaseSearchProcessor.test.ts b/serverless/lib/__tests__/CaseSearchProcessor.test.ts index cde27db..9e5b3bd 100644 --- a/serverless/lib/__tests__/CaseSearchProcessor.test.ts +++ b/serverless/lib/__tests__/CaseSearchProcessor.test.ts @@ -40,6 +40,7 @@ describe('CaseSearchProcessor', () => { caseName: 'Test vs State', court: 'Test Court', charges: [], + filingAgency: null, }; const completeCase: SearchResult = { @@ -284,6 +285,7 @@ describe('CaseSearchProcessor', () => { caseName: 'Test vs State', court: 'Test Court', charges: [], + filingAgency: null, }; mockStorageClient.getSearchResults.mockResolvedValue({ diff --git a/serverless/lib/__tests__/caseProcessor.test.ts b/serverless/lib/__tests__/caseProcessor.test.ts index e71a660..a0b07d7 100644 --- a/serverless/lib/__tests__/caseProcessor.test.ts +++ b/serverless/lib/__tests__/caseProcessor.test.ts @@ -276,5 +276,225 @@ describe('CaseProcessor', () => { expect(summary?.arrestOrCitationDate).toBeUndefined(); expect(summary?.arrestOrCitationType).toBeUndefined(); }); + + it('sets top-level filing agency when single charge has filing agency', () => { + const rawData = { + summary: { + CaseSummaryHeader: { + Style: 'State vs. SingleCharge', + Heading: 'Circuit Court', + CaseId: 'case-f1', + }, + }, + charges: { + Charges: [ + { + ChargeId: 10, + OffenseDate: '2020-01-01', + FiledDate: '2020-01-02', + FilingAgencyDescription: 'Metro PD', + ChargeOffense: { + ChargeOffenseDescription: 'Assault', + Statute: '456', + Degree: 'F', + DegreeDescription: 'Felony', + FineAmount: 0, + }, + }, + ], + }, + dispositionEvents: { Events: [] }, + caseEvents: { Events: [] }, + }; + + const summary = buildCaseSummary(rawData); + + expect(summary).not.toBeNull(); + expect(summary?.filingAgency).toBe('Metro PD'); + expect(summary?.charges[0].filingAgency).toBe('Metro PD'); + }); + + it('sets top-level filing agency when multiple charges share same agency and some charges lack agency', () => { + const rawData = { + summary: { + CaseSummaryHeader: { + Style: 'State vs. MultiCharge', + Heading: 'Circuit Court', + CaseId: 'case-f2', + }, + }, + charges: { + Charges: [ + { + ChargeId: 11, + OffenseDate: '2020-01-01', + FiledDate: '2020-01-02', + FilingAgencyDescription: 'County Sheriff', + ChargeOffense: { + ChargeOffenseDescription: 'Burglary', + Statute: '789', + Degree: 'F', + DegreeDescription: 'Felony', + FineAmount: 0, + }, + }, + { + ChargeId: 12, + OffenseDate: '2020-02-01', + FiledDate: '2020-02-02', + // No FilingAgencyDescription on this charge + ChargeOffense: { + ChargeOffenseDescription: 'Robbery', + Statute: '321', + Degree: 'F', + DegreeDescription: 'Felony', + FineAmount: 0, + }, + }, + { + ChargeId: 13, + OffenseDate: '2020-03-01', + FiledDate: '2020-03-02', + FilingAgencyDescription: 'County Sheriff', + ChargeOffense: { + ChargeOffenseDescription: 'Theft', + Statute: '123', + Degree: 'M', + DegreeDescription: 'Misdemeanor', + FineAmount: 0, + }, + }, + ], + }, + dispositionEvents: { Events: [] }, + caseEvents: { Events: [] }, + }; + + const summary = buildCaseSummary(rawData); + + expect(summary).not.toBeNull(); + expect(summary?.filingAgency).toBe('County Sheriff'); + // Charges should retain any per-charge filingAgency where present + expect(summary?.charges.find((ch: any) => ch.offenseDate === '2020-01-01')?.filingAgency).toBe('County Sheriff'); + expect(summary?.charges.find((ch: any) => ch.offenseDate === '2020-02-01')?.filingAgency).toBeNull(); + }); + + it('does not set top-level filing agency when charges have differing agencies', () => { + const rawData = { + summary: { + CaseSummaryHeader: { + Style: 'State vs. DifferentAgencies', + Heading: 'Circuit Court', + CaseId: 'case-f3', + }, + }, + charges: { + Charges: [ + { + ChargeId: 21, + OffenseDate: '2020-04-01', + FiledDate: '2020-04-02', + FilingAgencyDescription: 'Dept A', + ChargeOffense: { + ChargeOffenseDescription: 'Charge A', + Statute: '111', + Degree: 'M', + DegreeDescription: 'M', + FineAmount: 0, + }, + }, + { + ChargeId: 22, + OffenseDate: '2020-05-01', + FiledDate: '2020-05-02', + FilingAgencyDescription: 'Dept B', + ChargeOffense: { + ChargeOffenseDescription: 'Charge B', + Statute: '222', + Degree: 'M', + DegreeDescription: 'M', + FineAmount: 0, + }, + }, + ], + }, + dispositionEvents: { Events: [] }, + caseEvents: { Events: [] }, + }; + + const summary = buildCaseSummary(rawData); + + expect(summary).not.toBeNull(); + expect(summary?.filingAgency).toBeNull(); + expect(summary?.charges[0].filingAgency).toBe('Dept A'); + expect(summary?.charges[1].filingAgency).toBe('Dept B'); + }); + + it('does not set filing agency when none present on charges', () => { + const rawData = { + summary: { CaseSummaryHeader: { Style: 'No Agency', Heading: 'Circuit Court', CaseId: 'case-f4' } }, + charges: { + Charges: [ + { + ChargeId: 31, + OffenseDate: '2020-06-01', + FiledDate: '2020-06-02', + ChargeOffense: { + ChargeOffenseDescription: 'NoAgency', + Statute: '000', + Degree: 'M', + DegreeDescription: 'M', + FineAmount: 0, + }, + }, + ], + }, + dispositionEvents: { Events: [] }, + caseEvents: { Events: [] }, + }; + + const summary = buildCaseSummary(rawData); + expect(summary).not.toBeNull(); + expect(summary?.filingAgency).toBeNull(); + expect(summary?.charges[0].filingAgency).toBeNull(); + expect(summary?.charges[0].filingAgencyAddress).toEqual([]); + }); + + it('sets filingAgencyAddress array on charge when provided as array', () => { + const rawData = { + summary: { + CaseSummaryHeader: { + Style: 'State vs. SingleCharge', + Heading: 'Circuit Court', + CaseId: 'case-fa1', + }, + }, + charges: { + Charges: [ + { + ChargeId: 50, + OffenseDate: '2020-07-01', + FiledDate: '2020-07-02', + FilingAgencyDescription: 'Metro PD', + FilingAgencyAddress: ['123 Main St', 'Suite 200'], + ChargeOffense: { + ChargeOffenseDescription: 'Assault', + Statute: '456', + Degree: 'F', + DegreeDescription: 'Felony', + FineAmount: 0, + }, + }, + ], + }, + dispositionEvents: { Events: [] }, + caseEvents: { Events: [] }, + }; + + const summary = buildCaseSummary(rawData); + + expect(summary).not.toBeNull(); + expect(summary?.charges[0].filingAgencyAddress).toEqual(['123 Main St', 'Suite 200']); + }); }); }); diff --git a/serverless/lib/__tests__/storageClient.test.ts b/serverless/lib/__tests__/storageClient.test.ts index 556f992..aa71776 100644 --- a/serverless/lib/__tests__/storageClient.test.ts +++ b/serverless/lib/__tests__/storageClient.test.ts @@ -211,8 +211,8 @@ describe('StorageClient.getSearchResults resilience', () => { caseName: 'State vs Valid Defendant', court: 'Test Superior Court', charges: [ - { description: 'Charge 1', statute: 'ABC-123' }, - { description: 'Charge 2', statute: 'DEF-456' }, + { description: 'Charge 1', statute: 'ABC-123', filingAgency: null, filingAgencyAddress: [] }, + { description: 'Charge 2', statute: 'DEF-456', filingAgency: null, filingAgencyAddress: [] }, ], }; @@ -258,7 +258,7 @@ describe('StorageClient.getSearchResults resilience', () => { const summaryMissingName = { // caseName is missing court: 'Test Court', - charges: [{ description: 'Valid charge' }], + charges: [{ description: 'Valid charge', filingAgency: null, filingAgencyAddress: [] }], }; const result = await validateAndProcessCaseSummary(caseNumber, caseData, summaryMissingName); @@ -280,7 +280,7 @@ describe('StorageClient.getSearchResults resilience', () => { const summaryMissingCourt = { caseName: 'State vs Defendant', // court is missing - charges: [{ description: 'Valid charge' }], + charges: [{ description: 'Valid charge', filingAgency: null, filingAgencyAddress: [] }], }; const result = await validateAndProcessCaseSummary(caseNumber, caseData, summaryMissingCourt); @@ -360,8 +360,16 @@ describe('StorageClient.getSearchResults resilience', () => { { caseNumber: 'VALID001', caseData: { caseNumber: 'VALID001', caseId: 'id1', fetchStatus: { status: 'complete' } }, - summaryItem: { caseName: 'Valid Case 1', court: 'Court 1', charges: [{ description: 'Charge 1' }] }, - expectedResult: { caseName: 'Valid Case 1', court: 'Court 1', charges: [{ description: 'Charge 1' }] }, + summaryItem: { + caseName: 'Valid Case 1', + court: 'Court 1', + charges: [{ description: 'Charge 1', filingAgency: null, filingAgencyAddress: [] }], + }, + expectedResult: { + caseName: 'Valid Case 1', + court: 'Court 1', + charges: [{ description: 'Charge 1', filingAgency: null, filingAgencyAddress: [] }], + }, }, { caseNumber: 'CORRUPT002', @@ -372,8 +380,16 @@ describe('StorageClient.getSearchResults resilience', () => { { caseNumber: 'VALID003', caseData: { caseNumber: 'VALID003', caseId: 'id3', fetchStatus: { status: 'complete' } }, - summaryItem: { caseName: 'Valid Case 3', court: 'Court 3', charges: [{ description: 'Charge 3' }] }, - expectedResult: { caseName: 'Valid Case 3', court: 'Court 3', charges: [{ description: 'Charge 3' }] }, + summaryItem: { + caseName: 'Valid Case 3', + court: 'Court 3', + charges: [{ description: 'Charge 3', filingAgency: null, filingAgencyAddress: [] }], + }, + expectedResult: { + caseName: 'Valid Case 3', + court: 'Court 3', + charges: [{ description: 'Charge 3', filingAgency: null, filingAgencyAddress: [] }], + }, }, ]; diff --git a/shared/types/ZipCase.ts b/shared/types/ZipCase.ts index 1927a45..368ffac 100644 --- a/shared/types/ZipCase.ts +++ b/shared/types/ZipCase.ts @@ -26,6 +26,8 @@ export interface Charge { degree: ChargeDegree; fine: number; dispositions: Disposition[]; + filingAgency: string | null; + filingAgencyAddress: string[]; } export type ArrestOrCitationType = 'Arrest' | 'Citation'; @@ -36,6 +38,7 @@ export interface CaseSummary { charges: Charge[]; arrestOrCitationDate?: string; arrestOrCitationType?: ArrestOrCitationType; + filingAgency: string | null; } export interface ZipCase {