Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions frontend/src/components/app/SearchResult.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
<div className="text-sm text-gray-700">
<p className="font-medium">{summary.caseName}</p>
<p>{summary.court}</p>
{summary.arrestOrCitationDate &&
(() => {
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 (
<p className="mt-1 text-sm text-gray-600">
<span className="font-medium">{label}</span> {d.toLocaleDateString()}
</p>
);
}
return null;
})()}
</div>

{summary.charges && summary.charges.length > 0 && (
Expand All @@ -76,11 +95,17 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
<div>
<span className="font-medium">Filed:</span>{' '}
{new Date(charge.filedDate).toLocaleDateString()}
{(() => {
const d = new Date(charge.filedDate);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
})()}
</div>
<div>
<span className="font-medium">Offense:</span>{' '}
{new Date(charge.offenseDate).toLocaleDateString()}
{(() => {
const d = new Date(charge.offenseDate);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
})()}
</div>
<div>
<span className="font-medium">Statute:</span> {charge.statute}
Expand All @@ -98,7 +123,11 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
<div className="mt-2 text-xs text-gray-600">
<span className="font-medium">Disposition:</span>{' '}
{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();
})()}
)
</div>
)}
</div>
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/components/app/__tests__/SearchResult.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,53 @@ 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',
arrestOrCitationType: 'Arrest',
charges: [],
},
});

render(<SearchResult searchResult={testCase} />);

// 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();
});

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(<SearchResult searchResult={testCase} />);

// 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();
});
});
89 changes: 84 additions & 5 deletions serverless/lib/CaseProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,8 +502,32 @@ const caseEndpoints: Record<string, EndpointConfig> = {
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<CaseSummary | null> {
try {
const portalCaseUrl = process.env.PORTAL_CASE_URL;
Expand Down Expand Up @@ -578,8 +602,8 @@ async function fetchCaseSummary(caseId: string): Promise<CaseSummary | null> {
// 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}`);
Expand Down Expand Up @@ -651,10 +675,10 @@ function buildCaseSummary(rawData: Record<string, PortalApiResponse>): 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'
Expand Down Expand Up @@ -721,6 +745,60 @@ function buildCaseSummary(rawData: Record<string, PortalApiResponse>): CaseSumma
});
});

// 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 (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']
);

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 (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) {
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, {
Expand All @@ -736,6 +814,7 @@ const CaseProcessor = {
processCaseData,
queueCasesForSearch,
fetchCaseIdFromPortal,
buildCaseSummary,
};

export default CaseProcessor;
137 changes: 137 additions & 0 deletions serverless/lib/__tests__/caseProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,141 @@ 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 and type as Arrest', () => {
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();
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('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: {
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();
expect(summary?.arrestOrCitationType).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();
expect(summary?.arrestOrCitationType).toBeUndefined();
});
});
});
Loading