diff --git a/frontend/src/components/app/SearchResult.tsx b/frontend/src/components/app/SearchResult.tsx index 0f750de..2eb001d 100644 --- a/frontend/src/components/app/SearchResult.tsx +++ b/frontend/src/components/app/SearchResult.tsx @@ -2,14 +2,17 @@ import React from 'react'; import type { SearchResult as SearchResultType } from '../../../../shared/types'; import { parseDateString, formatDisplayDate } from '../../../../shared/DateTimeUtils'; import SearchStatus from './SearchStatus'; -import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'; +import { ArrowTopRightOnSquareIcon, 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'; interface SearchResultProps { searchResult: SearchResultType; } const SearchResult: React.FC = ({ searchResult: sr }) => { + const removeCase = useRemoveCase(); // 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); @@ -18,8 +21,21 @@ const SearchResult: React.FC = ({ searchResult: sr }) => { const { zipCase: c, caseSummary: summary } = sr; + const handleRemove = () => { + removeCase(c.caseNumber); + }; + return ( -
+
+ {/* Remove button - appears in upper right corner */} + + +
diff --git a/frontend/src/components/app/__tests__/SearchResult.test.tsx b/frontend/src/components/app/__tests__/SearchResult.test.tsx index 876f43b..4dd017d 100644 --- a/frontend/src/components/app/__tests__/SearchResult.test.tsx +++ b/frontend/src/components/app/__tests__/SearchResult.test.tsx @@ -3,6 +3,26 @@ import { describe, expect, it, vi } from 'vitest'; import '@testing-library/jest-dom'; import SearchResult from '../SearchResult'; import { SearchResult as SearchResultType, ZipCase } from '../../../../../shared/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +// Create a test query client wrapper +const createTestQueryClient = () => { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +}; + +const createWrapper = (queryClient?: QueryClient) => { + const testQueryClient = queryClient || createTestQueryClient(); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; // Mock SearchStatus component vi.mock('../SearchStatus', () => ({ @@ -15,6 +35,7 @@ vi.mock('../SearchStatus', () => ({ // Mock constants from aws-exports vi.mock('../../../aws-exports', () => ({ + API_URL: 'https://api.example.com', PORTAL_URL: 'https://portal.example.com', PORTAL_CASE_URL: 'https://portal.example.com/search-results', })); @@ -62,7 +83,7 @@ const createTestCase = (override = {}): SearchResultType => ({ describe('SearchResult component', () => { it('renders case information correctly', () => { const testCase = createTestCase(); - render(); + render(, { wrapper: createWrapper() }); // Check case number is displayed expect(screen.getByText('22CR123456-789')).toBeInTheDocument(); @@ -81,7 +102,7 @@ describe('SearchResult component', () => { it('renders case as a link when caseId is present', () => { const testCase = createTestCase(); - render(); + render(, { wrapper: createWrapper() }); // Check that case number is rendered as a link const link = screen.getByRole('link', { name: /22CR123456-789/ }); @@ -100,7 +121,7 @@ describe('SearchResult component', () => { }, }, }); - render(); + render(, { wrapper: createWrapper() }); // Check that case number is rendered as text, not a link expect(screen.queryByRole('link')).not.toBeInTheDocument(); @@ -117,7 +138,7 @@ describe('SearchResult component', () => { }, }, }); - render(); + render(, { wrapper: createWrapper() }); // Last updated text should not be present expect(screen.queryByText(/Last Updated:/)).not.toBeInTheDocument(); @@ -127,7 +148,7 @@ describe('SearchResult component', () => { const testCase = createTestCase({ caseSummary: undefined, }); - render(); + render(, { wrapper: createWrapper() }); // Summary information should not be present expect(screen.queryByText('State vs. Doe')).not.toBeInTheDocument(); @@ -140,7 +161,7 @@ describe('SearchResult component', () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); // Render with invalid data (missing caseNumber) - const { container } = render(); + const { container } = render(, { wrapper: createWrapper() }); // Component should render nothing expect(container).toBeEmptyDOMElement(); @@ -163,7 +184,7 @@ describe('SearchResult component', () => { }, }, }); - render(); + render(, { wrapper: createWrapper() }); // Check that error message is displayed expect(screen.getByText('Error: Failed to fetch case data')).toBeInTheDocument(); @@ -184,7 +205,7 @@ describe('SearchResult component', () => { }, }); - render(); + render(, { wrapper: createWrapper() }); // Label should be present and explicitly show 'Arrest Date' expect(screen.getByText(/Arrest Date:/)).toBeInTheDocument(); @@ -213,7 +234,7 @@ describe('SearchResult component', () => { }, }); - render(); + render(, { wrapper: createWrapper() }); // Should not render 'Invalid Date' anywhere expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument(); @@ -243,7 +264,7 @@ describe('SearchResult component', () => { }, }); - render(); + render(, { wrapper: createWrapper() }); // Top-level Filing Agency should be present expect(screen.getByText(/Filing Agency:/)).toBeInTheDocument(); @@ -284,7 +305,7 @@ describe('SearchResult component', () => { }, }); - render(); + render(, { wrapper: createWrapper() }); // No single top-level filing agency — expect per-charge Filing Agency labels for each charge const filingLabels = screen.queryAllByText(/Filing Agency:/); @@ -294,4 +315,14 @@ describe('SearchResult component', () => { expect(screen.getByText('Dept A')).toBeInTheDocument(); expect(screen.getByText('Dept B')).toBeInTheDocument(); }); + + it('renders remove button that appears on hover', () => { + const testCase = createTestCase(); + render(, { wrapper: createWrapper() }); + + // Remove button should be present + const removeButton = screen.getByRole('button', { name: /remove case from results/i }); + expect(removeButton).toBeInTheDocument(); + expect(removeButton).toHaveAttribute('title', 'Remove case from results'); + }); }); diff --git a/frontend/src/hooks/__tests__/useCaseSearch.test.tsx b/frontend/src/hooks/__tests__/useCaseSearch.test.tsx index fc2540e..80104b5 100644 --- a/frontend/src/hooks/__tests__/useCaseSearch.test.tsx +++ b/frontend/src/hooks/__tests__/useCaseSearch.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; -import { useSearchResults, useConsolidatedPolling } from '../useCaseSearch'; +import { useSearchResults, useConsolidatedPolling, useRemoveCase } from '../useCaseSearch'; vi.mock('../../aws-exports', () => ({ API_URL: 'http://test-api.example.com', @@ -167,3 +167,129 @@ describe('useConsolidatedPolling - state management', () => { }); }); }); + +describe('useRemoveCase', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should remove a case from results and batches', () => { + const testData = { + results: { + case123: { + zipCase: { + caseNumber: 'case123', + fetchStatus: { status: 'complete' }, + }, + }, + case456: { + zipCase: { + caseNumber: 'case456', + fetchStatus: { status: 'complete' }, + }, + }, + }, + searchBatches: [['case123', 'case456']], + }; + + const queryClient = createTestQueryClient(); + queryClient.setQueryData(['searchResults'], testData); + const wrapper = createWrapper(queryClient); + + const { result } = renderHook(() => useRemoveCase(), { wrapper }); + + // Remove case123 + act(() => { + result.current('case123'); + }); + + // Check that the case was removed + const updatedData = queryClient.getQueryData(['searchResults']); + expect(updatedData).toEqual({ + results: { + case456: { + zipCase: { + caseNumber: 'case456', + fetchStatus: { status: 'complete' }, + }, + }, + }, + searchBatches: [['case456']], + }); + }); + + it('should remove empty batches after removing all cases', () => { + const testData = { + results: { + case123: { + zipCase: { + caseNumber: 'case123', + fetchStatus: { status: 'complete' }, + }, + }, + }, + searchBatches: [['case123']], + }; + + const queryClient = createTestQueryClient(); + queryClient.setQueryData(['searchResults'], testData); + const wrapper = createWrapper(queryClient); + + const { result } = renderHook(() => useRemoveCase(), { wrapper }); + + // Remove case123 + act(() => { + result.current('case123'); + }); + + // Check that the batch was removed too + const updatedData = queryClient.getQueryData(['searchResults']); + expect(updatedData).toEqual({ + results: {}, + searchBatches: [], + }); + }); + + it('should handle removing a non-existent case gracefully', () => { + const testData = { + results: { + case123: { + zipCase: { + caseNumber: 'case123', + fetchStatus: { status: 'complete' }, + }, + }, + }, + searchBatches: [['case123']], + }; + + const queryClient = createTestQueryClient(); + queryClient.setQueryData(['searchResults'], testData); + const wrapper = createWrapper(queryClient); + + const { result } = renderHook(() => useRemoveCase(), { wrapper }); + + // Remove non-existent case + act(() => { + result.current('case999'); + }); + + // Check that the state remains the same + const updatedData = queryClient.getQueryData(['searchResults']); + expect(updatedData).toEqual({ + results: { + case123: { + zipCase: { + caseNumber: 'case123', + fetchStatus: { status: 'complete' }, + }, + }, + }, + searchBatches: [['case123']], + }); + }); +}); diff --git a/frontend/src/hooks/useCaseSearch.ts b/frontend/src/hooks/useCaseSearch.ts index 388c99e..77238db 100644 --- a/frontend/src/hooks/useCaseSearch.ts +++ b/frontend/src/hooks/useCaseSearch.ts @@ -92,6 +92,33 @@ export function useSearchResults() { }); } +export function useRemoveCase() { + const queryClient = useQueryClient(); + + return (caseNumber: string) => { + const currentState = queryClient.getQueryData(['searchResults']); + + if (!currentState) { + return; + } + + // Remove the case from results + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [caseNumber]: _removed, ...remainingResults } = currentState.results; + + // Remove the case from all batches + const updatedBatches = currentState.searchBatches + .map(batch => batch.filter(cn => cn !== caseNumber)) + .filter(batch => batch.length > 0); // Remove empty batches + + // Update the query cache + queryClient.setQueryData(['searchResults'], { + results: remainingResults, + searchBatches: updatedBatches, + }); + }; +} + export function useConsolidatedPolling() { const queryClient = useQueryClient(); diff --git a/serverless/app/serverless.yml b/serverless/app/serverless.yml index ec1294d..68d8c1b 100644 --- a/serverless/app/serverless.yml +++ b/serverless/app/serverless.yml @@ -329,7 +329,7 @@ functions: processCaseData: handler: handlers/case.processCaseData - timeout: 12 + timeout: 90 events: - sqs: arn: ${cf:infra-${self:provider.stage}.CaseDataQueueArn} diff --git a/serverless/lib/CaseProcessor.ts b/serverless/lib/CaseProcessor.ts index ee85db3..26fa290 100644 --- a/serverless/lib/CaseProcessor.ts +++ b/serverless/lib/CaseProcessor.ts @@ -120,9 +120,7 @@ async function processCaseSearchRecord( }); } else if (fetchStatus === 'processing') { // Handle processing timeout (5 minutes) - const lastUpdated = zipCase.lastUpdated - ? new Date(zipCase.lastUpdated) - : new Date(0); + const lastUpdated = zipCase.lastUpdated ? new Date(zipCase.lastUpdated) : new Date(0); const minutesDiff = (nowTime - lastUpdated.getTime()) / (1000 * 60); if (minutesDiff < 5) { @@ -252,12 +250,7 @@ async function processCaseSearchRecord( } // Process a case data message - responsible for fetching case details -async function processCaseDataRecord( - caseNumber: string, - caseId: string, - userId: string, - receiptHandle: string -): Promise { +async function processCaseDataRecord(caseNumber: string, caseId: string, userId: string, receiptHandle: string): Promise { try { // Check for existing data and skip if already complete with current schema version. const zipCase = await StorageClient.getCase(caseNumber); @@ -267,9 +260,7 @@ async function processCaseDataRecord( if (lastUpdated.getTime() >= CASE_SUMMARY_VERSION_DATE.getTime()) { // Cached summary is new enough for current version - use it await QueueClient.deleteMessage(receiptHandle, 'data'); - console.log( - `Case ${caseNumber} already complete and up-to-date (lastUpdated=${zipCase.lastUpdated}); using cached data` - ); + console.log(`Case ${caseNumber} already complete and up-to-date (lastUpdated=${zipCase.lastUpdated}); using cached data`); return zipCase.fetchStatus; } @@ -322,10 +313,7 @@ interface CaseSearchResult { }; } -async function fetchCaseIdFromPortal( - caseNumber: string, - cookieJar: CookieJar -): Promise { +async function fetchCaseIdFromPortal(caseNumber: string, cookieJar: CookieJar): Promise { try { // Get the portal URL from environment variable const portalUrl = process.env.PORTAL_URL; @@ -522,6 +510,66 @@ const caseEndpoints: Record = { }, }; +const ENDPOINT_FETCH_MAX_RETRIES = parseInt(process.env.ENDPOINT_FETCH_MAX_RETRIES || '3', 10); +const ENDPOINT_FETCH_RETRY_BASE_MS = parseInt(process.env.ENDPOINT_FETCH_RETRY_BASE_MS || '200', 10); + +export async function fetchWithRetry(client: any, url: string, key: string) { + let attempt = 0; + + while (attempt < ENDPOINT_FETCH_MAX_RETRIES) { + attempt += 1; + try { + const response = await client.get(url); + if (response.status === 200) { + return { key, success: true, data: response.data }; + } + + // Non-200 responses: decide whether retryable (5xx) + if (response.status >= 500 && attempt < ENDPOINT_FETCH_MAX_RETRIES) { + const delayMs = ENDPOINT_FETCH_RETRY_BASE_MS * Math.pow(2, attempt - 1); + console.warn( + `Transient server error fetching ${key} (status ${response.status}), retrying in ${delayMs}ms (attempt ${attempt})` + ); + await new Promise(res => setTimeout(res, delayMs)); + continue; + } + + return { key, success: false, error: `${key} request failed with status ${response.status}` }; + } catch (error) { + const err: any = error; + + // If axios returned a response, check its status + const status = err?.response?.status; + if (status && status >= 500 && attempt < ENDPOINT_FETCH_MAX_RETRIES) { + const delayMs = ENDPOINT_FETCH_RETRY_BASE_MS * Math.pow(2, attempt - 1); + console.warn(`Transient server error fetching ${key} (status ${status}), retrying in ${delayMs}ms (attempt ${attempt})`); + await new Promise(res => setTimeout(res, delayMs)); + continue; + } + + // Network-level or timeout errors are considered retryable + const isNetworkError = + !err?.response || err?.code === 'ECONNABORTED' || err?.code === 'ENOTFOUND' || err?.code === 'ECONNRESET'; + if (isNetworkError && attempt < ENDPOINT_FETCH_MAX_RETRIES) { + const delayMs = ENDPOINT_FETCH_RETRY_BASE_MS * Math.pow(2, attempt - 1); + console.warn(`Network error fetching ${key} (${err?.message}), retrying in ${delayMs}ms (attempt ${attempt})`); + await new Promise(res => setTimeout(res, delayMs)); + continue; + } + + // If this was a network error and we've exhausted attempts, return a standardized exhausted message + if (isNetworkError && attempt >= ENDPOINT_FETCH_MAX_RETRIES) { + return { key, success: false, error: `Failed to fetch ${key} after ${ENDPOINT_FETCH_MAX_RETRIES} attempts` }; + } + + // Not retryable or other error: return detailed error + return { key, success: false, error: `Error fetching ${key}: ${err?.message || String(err)}` }; + } + } + + return { key, success: false, error: `Failed to fetch ${key} after ${ENDPOINT_FETCH_MAX_RETRIES} attempts` }; +} + async function fetchCaseSummary(caseId: string): Promise { try { const portalCaseUrl = process.env.PORTAL_CASE_URL; @@ -563,22 +611,23 @@ async function fetchCaseSummary(caseId: string): Promise { const url = `${portalCaseUrl}${endpoint.path.replace('{caseId}', caseId)}`; console.log(`Fetching ${key} data from ${url}`); - const response = await client.get(url); - - if (response.status !== 200) { - const errorMessage = `${key} request failed with status ${response.status}`; + const fetchResult = await fetchWithRetry(client, url, key); - await AlertService.logError(Severity.ERROR, AlertCategory.PORTAL, `Failed to fetch ${key}`, new Error(errorMessage), { - caseId, - statusCode: response.status, - resource: key, - }); - - return { key, success: false, error: errorMessage }; + if (!fetchResult.success) { + await AlertService.logError( + Severity.ERROR, + AlertCategory.PORTAL, + `Failed to fetch ${key}`, + new Error(fetchResult.error), + { + caseId, + resource: key, + } + ); + return { key, success: false, error: fetchResult.error }; } - // Just store the raw response data - return { key, success: true, data: response.data }; + return { key, success: true, data: fetchResult.data }; } catch (error) { await AlertService.logError(Severity.ERROR, AlertCategory.PORTAL, `Error fetching ${key}`, error as Error, { caseId, @@ -596,11 +645,12 @@ async function fetchCaseSummary(caseId: string): Promise { // Wait for all promises to resolve const results = await Promise.all(endpointPromises); - // Treat caseEvents as optional; if any other endpoint failed, consider it a required failure - const requiredFailure = results.find(result => !result.success && result.key !== 'caseEvents'); + // Only the 'summary' endpoint is strictly required. Other endpoints are optional; + // if they fail we'll proceed with partial data but still alert which endpoints failed. + const summaryFailure = results.find(result => !result.success && result.key === 'summary'); - if (requiredFailure) { - console.error(`Required endpoint ${requiredFailure.key} failed: ${requiredFailure.error}`); + if (summaryFailure) { + console.error(`Required endpoint ${summaryFailure.key} failed: ${summaryFailure.error}`); return null; } @@ -624,11 +674,19 @@ async function fetchCaseSummary(caseId: string): Promise { function buildCaseSummary(rawData: Record): CaseSummary | null { try { - if (!rawData['summary'] || !rawData['charges'] || !rawData['dispositionEvents']) { - console.error('Missing required raw data for building case summary'); + if (!rawData['summary']) { + console.error('Missing required summary data for building case summary'); return null; } + // If other endpoints are missing, emit a warning but continue building a partial summary. + if (!rawData['charges']) { + console.warn('Charges data missing for case; building partial summary without charges'); + } + if (!rawData['dispositionEvents']) { + console.warn('Disposition events missing for case; building partial summary without dispositions'); + } + const caseSummary: CaseSummary = { caseName: rawData['summary']['CaseSummaryHeader']['Style'] || '', court: rawData['summary']['CaseSummaryHeader']['Heading'] || '', @@ -639,7 +697,7 @@ function buildCaseSummary(rawData: Record): CaseSumma const chargeMap = new Map(); // Process charges - const charges = rawData['charges']['Charges'] || []; + const charges = rawData['charges'] && rawData['charges']['Charges'] ? rawData['charges']['Charges'] : []; // eslint-disable-next-line @typescript-eslint/no-explicit-any charges.forEach((chargeData: any) => { if (!chargeData) return; @@ -699,7 +757,8 @@ function buildCaseSummary(rawData: Record): CaseSumma } // Process dispositions and link them to charges - const dispositionEvents = rawData['dispositionEvents']['Events'] || []; + const dispositionEvents = + rawData['dispositionEvents'] && rawData['dispositionEvents']['Events'] ? rawData['dispositionEvents']['Events'] : []; console.log(`📋 Found ${dispositionEvents.length} disposition events`); dispositionEvents diff --git a/serverless/lib/CaseSearchProcessor.ts b/serverless/lib/CaseSearchProcessor.ts index 675bc35..64e6c9d 100644 --- a/serverless/lib/CaseSearchProcessor.ts +++ b/serverless/lib/CaseSearchProcessor.ts @@ -365,8 +365,9 @@ export async function fetchCaseIdFromPortal(caseNumber: string, cookieJar: Cooki withCredentials: true, headers: { ...PortalAuthenticator.getDefaultRequestHeaders(userAgent), - Origin: portalUrl, 'Content-Type': 'application/x-www-form-urlencoded', + Origin: portalUrl, + Referer: 'https://portal-nc.tylertech.cloud/Portal/Home/Dashboard/29', }, }); diff --git a/serverless/lib/PortalAuthenticator.ts b/serverless/lib/PortalAuthenticator.ts index 2ac4572..33721d9 100644 --- a/serverless/lib/PortalAuthenticator.ts +++ b/serverless/lib/PortalAuthenticator.ts @@ -28,8 +28,10 @@ const DEFAULT_USER_AGENT = export function getDefaultRequestHeaders(userAgent?: string): Record { return { 'User-Agent': userAgent || DEFAULT_USER_AGENT, - Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-Encoding': 'gzip, deflate, br, zstd', 'Accept-Language': 'en-US,en;q=0.9', + 'Cache-Control': 'max-age=0', }; }