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
49 changes: 46 additions & 3 deletions frontend/src/components/app/SearchResult.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,32 @@ 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;
})()}

{/* Filing agency: shown at top-level if the case summary has a single filing agency for all charges */}
{summary.filingAgency && (
<p className="mt-1 text-sm text-gray-600">
<span className="font-medium">Filing Agency:</span> {summary.filingAgency}
</p>
)}
</div>

{summary.charges && summary.charges.length > 0 && (
Expand All @@ -76,11 +102,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 @@ -93,12 +125,23 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
<span className="font-medium">Fine:</span> ${charge.fine.toFixed(2)}
</div>
)}

{/* Per-charge filing agency: only shown when no top-level filing agency is present */}
{!summary.filingAgency && charge.filingAgency && (
<div>
<span className="font-medium">Filing Agency:</span> {charge.filingAgency}
</div>
)}
</div>
{charge.dispositions && charge.dispositions.length > 0 && (
<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
125 changes: 125 additions & 0 deletions frontend/src/components/app/__tests__/SearchResult.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,11 @@ const createTestCase = (override = {}): SearchResultType => ({
description: 'Guilty',
},
],
filingAgency: null,
filingAgencyAddress: [],
},
],
filingAgency: null,
},
...override,
});
Expand Down Expand Up @@ -169,4 +172,126 @@ 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();
});

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

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

// 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();
});
});
119 changes: 114 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 @@ -615,6 +639,7 @@ function buildCaseSummary(rawData: Record<string, PortalApiResponse>): CaseSumma
caseName: rawData['summary']['CaseSummaryHeader']['Style'] || '',
court: rawData['summary']['CaseSummaryHeader']['Heading'] || '',
charges: [],
filingAgency: null,
};

const chargeMap = new Map<number, Charge>();
Expand All @@ -639,8 +664,21 @@ function buildCaseSummary(rawData: Record<string, PortalApiResponse>): 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);

Expand All @@ -650,11 +688,27 @@ function buildCaseSummary(rawData: Record<string, PortalApiResponse>): 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 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 +775,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 +844,7 @@ const CaseProcessor = {
processCaseData,
queueCasesForSearch,
fetchCaseIdFromPortal,
buildCaseSummary,
};

export default CaseProcessor;
Loading