Skip to content

Commit 940736e

Browse files
authored
Deploy ZipCase (#133)
2 parents 7165e9c + db679b3 commit 940736e

7 files changed

Lines changed: 699 additions & 16 deletions

File tree

frontend/src/components/app/SearchResult.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,32 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
6464
<div className="text-sm text-gray-700">
6565
<p className="font-medium">{summary.caseName}</p>
6666
<p>{summary.court}</p>
67+
{summary.arrestOrCitationDate &&
68+
(() => {
69+
const d = new Date(summary.arrestOrCitationDate);
70+
if (!isNaN(d.getTime())) {
71+
const label =
72+
summary.arrestOrCitationType === 'Arrest'
73+
? 'Arrest Date:'
74+
: summary.arrestOrCitationType === 'Citation'
75+
? 'Citation Date:'
76+
: 'Arrest/Citation Date:';
77+
78+
return (
79+
<p className="mt-1 text-sm text-gray-600">
80+
<span className="font-medium">{label}</span> {d.toLocaleDateString()}
81+
</p>
82+
);
83+
}
84+
return null;
85+
})()}
86+
87+
{/* Filing agency: shown at top-level if the case summary has a single filing agency for all charges */}
88+
{summary.filingAgency && (
89+
<p className="mt-1 text-sm text-gray-600">
90+
<span className="font-medium">Filing Agency:</span> {summary.filingAgency}
91+
</p>
92+
)}
6793
</div>
6894

6995
{summary.charges && summary.charges.length > 0 && (
@@ -76,11 +102,17 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
76102
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
77103
<div>
78104
<span className="font-medium">Filed:</span>{' '}
79-
{new Date(charge.filedDate).toLocaleDateString()}
105+
{(() => {
106+
const d = new Date(charge.filedDate);
107+
return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
108+
})()}
80109
</div>
81110
<div>
82111
<span className="font-medium">Offense:</span>{' '}
83-
{new Date(charge.offenseDate).toLocaleDateString()}
112+
{(() => {
113+
const d = new Date(charge.offenseDate);
114+
return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
115+
})()}
84116
</div>
85117
<div>
86118
<span className="font-medium">Statute:</span> {charge.statute}
@@ -93,12 +125,23 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
93125
<span className="font-medium">Fine:</span> ${charge.fine.toFixed(2)}
94126
</div>
95127
)}
128+
129+
{/* Per-charge filing agency: only shown when no top-level filing agency is present */}
130+
{!summary.filingAgency && charge.filingAgency && (
131+
<div>
132+
<span className="font-medium">Filing Agency:</span> {charge.filingAgency}
133+
</div>
134+
)}
96135
</div>
97136
{charge.dispositions && charge.dispositions.length > 0 && (
98137
<div className="mt-2 text-xs text-gray-600">
99138
<span className="font-medium">Disposition:</span>{' '}
100139
{charge.dispositions[0].description} (
101-
{new Date(charge.dispositions[0].date).toLocaleDateString()})
140+
{(() => {
141+
const d = new Date(charge.dispositions[0].date);
142+
return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
143+
})()}
144+
)
102145
</div>
103146
)}
104147
</div>

frontend/src/components/app/__tests__/SearchResult.test.tsx

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ const createTestCase = (override = {}): SearchResultType => ({
5050
description: 'Guilty',
5151
},
5252
],
53+
filingAgency: null,
54+
filingAgencyAddress: [],
5355
},
5456
],
57+
filingAgency: null,
5558
},
5659
...override,
5760
});
@@ -169,4 +172,126 @@ describe('SearchResult component', () => {
169172
const errorMessage = screen.getByText('Error: Failed to fetch case data');
170173
expect(errorMessage).toHaveClass('text-sm', 'text-red-600');
171174
});
175+
176+
it('displays arrest/citation date when present', () => {
177+
const testCase = createTestCase({
178+
caseSummary: {
179+
caseName: 'State vs. Doe',
180+
court: 'Circuit Court',
181+
arrestOrCitationDate: '2022-02-15T00:00:00Z',
182+
arrestOrCitationType: 'Arrest',
183+
charges: [],
184+
},
185+
});
186+
187+
render(<SearchResult searchResult={testCase} />);
188+
189+
// Label should be present and explicitly show 'Arrest Date'
190+
expect(screen.getByText(/Arrest Date:/)).toBeInTheDocument();
191+
192+
// The displayed date should contain the year 2022 (locale independent check)
193+
expect(screen.getByText(/2022/)).toBeInTheDocument();
194+
});
195+
196+
it('handles malformed charge dates gracefully', () => {
197+
const testCase = createTestCase({
198+
caseSummary: {
199+
caseName: 'State vs. Doe',
200+
court: 'Circuit Court',
201+
arrestOrCitationDate: 'not-a-date',
202+
charges: [
203+
{
204+
offenseDate: 'also-not-a-date',
205+
filedDate: 'not-a-date',
206+
description: 'Weird Charge',
207+
statute: '000',
208+
degree: { code: 'X', description: 'Unknown' },
209+
fine: 0,
210+
dispositions: [{ date: 'bad-date', code: 'UNK', description: 'Disposition ' }],
211+
},
212+
],
213+
},
214+
});
215+
216+
render(<SearchResult searchResult={testCase} />);
217+
218+
// Should not render 'Invalid Date' anywhere
219+
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
220+
221+
// Should still render the charge description
222+
expect(screen.getByText('Weird Charge')).toBeInTheDocument();
223+
});
224+
225+
it('displays top-level filing agency when present', () => {
226+
const testCase = createTestCase({
227+
caseSummary: {
228+
caseName: 'State vs. Doe',
229+
court: 'Circuit Court',
230+
filingAgency: 'Metro PD',
231+
charges: [
232+
{
233+
offenseDate: '2022-01-01',
234+
filedDate: '2022-01-02',
235+
description: 'Theft',
236+
statute: '123.456',
237+
degree: { code: 'M', description: 'Misdemeanor' },
238+
fine: 0,
239+
dispositions: [],
240+
filingAgency: 'Metro PD',
241+
},
242+
],
243+
},
244+
});
245+
246+
render(<SearchResult searchResult={testCase} />);
247+
248+
// Top-level Filing Agency should be present
249+
expect(screen.getByText(/Filing Agency:/)).toBeInTheDocument();
250+
expect(screen.getByText('Metro PD')).toBeInTheDocument();
251+
252+
// Per-charge filing agency should not be duplicated when top-level present
253+
const chargeAgency = screen.queryAllByText(/Filing Agency:/).length;
254+
expect(chargeAgency).toBe(1); // only the top-level label
255+
});
256+
257+
it('displays per-charge filing agencies when they differ and no top-level is set', () => {
258+
const testCase = createTestCase({
259+
caseSummary: {
260+
caseName: 'State vs. Doe',
261+
court: 'Circuit Court',
262+
charges: [
263+
{
264+
offenseDate: '2022-01-01',
265+
filedDate: '2022-01-02',
266+
description: 'Charge A',
267+
statute: '111',
268+
degree: { code: 'M', description: 'M' },
269+
fine: 0,
270+
dispositions: [],
271+
filingAgency: 'Dept A',
272+
},
273+
{
274+
offenseDate: '2022-02-01',
275+
filedDate: '2022-02-02',
276+
description: 'Charge B',
277+
statute: '222',
278+
degree: { code: 'M', description: 'M' },
279+
fine: 0,
280+
dispositions: [],
281+
filingAgency: 'Dept B',
282+
},
283+
],
284+
},
285+
});
286+
287+
render(<SearchResult searchResult={testCase} />);
288+
289+
// No single top-level filing agency — expect per-charge Filing Agency labels for each charge
290+
const filingLabels = screen.queryAllByText(/Filing Agency:/);
291+
expect(filingLabels.length).toBeGreaterThanOrEqual(2);
292+
293+
// Both per-charge filing agencies should be present
294+
expect(screen.getByText('Dept A')).toBeInTheDocument();
295+
expect(screen.getByText('Dept B')).toBeInTheDocument();
296+
});
172297
});

serverless/lib/CaseProcessor.ts

Lines changed: 114 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -502,8 +502,32 @@ const caseEndpoints: Record<string, EndpointConfig> = {
502502
financialSummary: {
503503
path: "Service/FinancialSummary('{caseId}')",
504504
},
505+
caseEvents: {
506+
path: "Service/CaseEvents('{caseId}')?top=200",
507+
},
505508
};
506509

510+
function parseMMddyyyyToDate(dateStr: string): Date | null {
511+
if (!dateStr || typeof dateStr !== 'string') {
512+
return null;
513+
}
514+
515+
const parts = dateStr.split('/');
516+
if (parts.length !== 3) {
517+
return null;
518+
}
519+
520+
const month = parseInt(parts[0], 10);
521+
const day = parseInt(parts[1], 10);
522+
const year = parseInt(parts[2], 10);
523+
524+
if (Number.isNaN(month) || Number.isNaN(day) || Number.isNaN(year)) {
525+
return null;
526+
}
527+
528+
return new Date(Date.UTC(year, month - 1, day));
529+
}
530+
507531
async function fetchCaseSummary(caseId: string): Promise<CaseSummary | null> {
508532
try {
509533
const portalCaseUrl = process.env.PORTAL_CASE_URL;
@@ -578,8 +602,8 @@ async function fetchCaseSummary(caseId: string): Promise<CaseSummary | null> {
578602
// Wait for all promises to resolve
579603
const results = await Promise.all(endpointPromises);
580604

581-
// Check if any endpoint failed
582-
const requiredFailure = results.find(result => !result.success);
605+
// Treat caseEvents as optional; if any other endpoint failed, consider it a required failure
606+
const requiredFailure = results.find(result => !result.success && result.key !== 'caseEvents');
583607

584608
if (requiredFailure) {
585609
console.error(`Required endpoint ${requiredFailure.key} failed: ${requiredFailure.error}`);
@@ -615,6 +639,7 @@ function buildCaseSummary(rawData: Record<string, PortalApiResponse>): CaseSumma
615639
caseName: rawData['summary']['CaseSummaryHeader']['Style'] || '',
616640
court: rawData['summary']['CaseSummaryHeader']['Heading'] || '',
617641
charges: [],
642+
filingAgency: null,
618643
};
619644

620645
const chargeMap = new Map<number, Charge>();
@@ -639,8 +664,21 @@ function buildCaseSummary(rawData: Record<string, PortalApiResponse>): CaseSumma
639664
},
640665
fine: typeof chargeOffense['FineAmount'] === 'number' ? chargeOffense['FineAmount'] : 0,
641666
dispositions: [],
667+
filingAgency: null,
668+
filingAgencyAddress: [],
642669
};
643670

671+
const filingAgencyRaw = chargeData['FilingAgencyDescription'];
672+
if (filingAgencyRaw) {
673+
charge.filingAgency = String(filingAgencyRaw).trim();
674+
}
675+
676+
// Extract filing agency address if present. It will be an array of strings.
677+
const filingAgencyAddressRaw = chargeData['FilingAgencyAddress'];
678+
if (filingAgencyAddressRaw) {
679+
charge.filingAgencyAddress.push(...(filingAgencyAddressRaw as any));
680+
}
681+
644682
// Add to charges array
645683
caseSummary.charges.push(charge);
646684

@@ -650,11 +688,27 @@ function buildCaseSummary(rawData: Record<string, PortalApiResponse>): CaseSumma
650688
}
651689
});
652690

691+
// After processing charges, derive top-level filing agency if appropriate
692+
try {
693+
const definedAgencies = caseSummary.charges.map(ch => ch.filingAgency).filter((a): a is string => a !== null && a.length > 0);
694+
695+
const uniqueAgencies = Array.from(new Set(definedAgencies));
696+
697+
// If there's at least one defined agency, and all defined agencies are identical,
698+
// set it on the case summary. Charges that lack an agency (null) are ignored for this decision.
699+
if (uniqueAgencies.length === 1 && uniqueAgencies[0]) {
700+
caseSummary.filingAgency = uniqueAgencies[0];
701+
console.log(`🔔 Set Filing Agency to ${caseSummary.filingAgency}`);
702+
}
703+
} catch (faErr) {
704+
console.error('Error computing top-level filing agency:', faErr);
705+
}
706+
653707
// Process dispositions and link them to charges
654-
const events = rawData['dispositionEvents']['Events'] || [];
655-
console.log(`📋 Found ${events.length} disposition events`);
708+
const dispositionEvents = rawData['dispositionEvents']['Events'] || [];
709+
console.log(`📋 Found ${dispositionEvents.length} disposition events`);
656710

657-
events
711+
dispositionEvents
658712
.filter(
659713
// eslint-disable-next-line @typescript-eslint/no-explicit-any
660714
(eventData: any) => eventData && eventData['Type'] === 'CriminalDispositionEvent'
@@ -721,6 +775,60 @@ function buildCaseSummary(rawData: Record<string, PortalApiResponse>): CaseSumma
721775
});
722776
});
723777

778+
// Process case-level events to determine arrest or citation date (LPSD -> Arrest, CIT -> Citation)
779+
try {
780+
const caseEvents = rawData['caseEvents']?.['Events'] || [];
781+
console.log(`📋 Found ${caseEvents.length} case events`);
782+
783+
// Filter only events that have the LPSD (arrest) or CIT (citation) TypeId and a valid EventDate
784+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
785+
const candidateEvents = caseEvents.filter(
786+
(ev: any) => ev && ev['Event'] && ev['Event']['TypeId'] && ev['Event']['TypeId']['Word'] && ev['Event']['EventDate']
787+
);
788+
789+
console.log(`🔎 Found ${candidateEvents.length} candidate events for arrest/citation`);
790+
791+
if (candidateEvents.length > 0) {
792+
const parsedCandidates: { date: Date; type: 'Arrest' | 'Citation'; raw: string }[] = [];
793+
794+
candidateEvents.forEach((ev: any, idx: number) => {
795+
const typeWord = ev['Event']['TypeId']['Word'];
796+
const eventDateStr = ev['Event']['EventDate'];
797+
798+
if (typeWord !== 'LPSD' && typeWord !== 'CIT') {
799+
return;
800+
}
801+
802+
const parsed = parseMMddyyyyToDate(eventDateStr);
803+
if (parsed) {
804+
parsedCandidates.push({
805+
date: parsed,
806+
type: typeWord === 'LPSD' ? 'Arrest' : 'Citation',
807+
raw: eventDateStr,
808+
});
809+
console.log(` ✔ Candidate #${idx}: Type=${typeWord}, Parsed=${parsed.toISOString()}`);
810+
} else {
811+
console.warn(` ✖ Candidate #${idx} has unparseable date: ${eventDateStr}`);
812+
}
813+
});
814+
815+
if (parsedCandidates.length > 0) {
816+
// Choose the earliest date among all matching candidates
817+
const earliest = parsedCandidates.reduce(
818+
(min, c) => (c.date.getTime() < min.date.getTime() ? c : min),
819+
parsedCandidates[0]
820+
);
821+
caseSummary.arrestOrCitationDate = earliest.date.toISOString();
822+
caseSummary.arrestOrCitationType = earliest.type;
823+
console.log(`🔔 Set ${earliest.type} date to ${caseSummary.arrestOrCitationDate}`);
824+
} else {
825+
console.log('No parsable arrest/citation dates found among candidates');
826+
}
827+
}
828+
} catch (evtErr) {
829+
console.error('Error processing caseEvents for arrest/citation date:', evtErr);
830+
}
831+
724832
return caseSummary;
725833
} catch (error) {
726834
AlertService.logError(Severity.ERROR, AlertCategory.SYSTEM, 'Error building case summary from raw data', error as Error, {
@@ -736,6 +844,7 @@ const CaseProcessor = {
736844
processCaseData,
737845
queueCasesForSearch,
738846
fetchCaseIdFromPortal,
847+
buildCaseSummary,
739848
};
740849

741850
export default CaseProcessor;

0 commit comments

Comments
 (0)