diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ccbaa319..451e2d09 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -71,6 +71,7 @@ model Contact { agreementEndDate DateTime? @map("agreement_end_date") @db.Date terminationDate DateTime? @map("termination_date") @db.Date mcfdContract String? @map("mcfd_contract") + sourceAgreement String? @map("source_agreement") orderNumber String? @map("order_number") orderType String? @map("order_type") orderStatus String? @map("order_status") diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index a8262986..9cc1f02c 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -207,6 +207,7 @@ function generateContact(csaStatus: string) { agreementEndDate: agreementEnd ?? null, terminationDate: terminationDate ?? null, mcfdContract: faker.string.alphanumeric(10).toUpperCase(), + sourceAgreement: faker.helpers.arrayElement(SOURCES), orderNumber: faker.string.alphanumeric(8).toUpperCase(), orderType: faker.helpers.arrayElement(ORDER_TYPES), diff --git a/backend/src/api/contacts/dto/contact.dto.ts b/backend/src/api/contacts/dto/contact.dto.ts index 6fef8756..942202b1 100644 --- a/backend/src/api/contacts/dto/contact.dto.ts +++ b/backend/src/api/contacts/dto/contact.dto.ts @@ -141,7 +141,7 @@ export class ContactDto { @ApiPropertyOptional({ description: 'Whether placement was interrupted' }) interruptedPlacement?: string - @ApiPropertyOptional({ description: 'Source of placement' }) + @ApiPropertyOptional({ description: 'Source of the primary placement (ICM or MIS)' }) sourcePlacement?: string @ApiPropertyOptional({ description: 'Service provider name' }) @@ -171,6 +171,9 @@ export class ContactDto { @ApiPropertyOptional({ description: 'MCFD contract identifier' }) mcfdContract?: string + @ApiPropertyOptional({ description: 'Source of the primary agreement or contract (ICM or MIS)' }) + sourceAgreement?: string + @ApiPropertyOptional({ description: 'Order number' }) orderNumber?: string diff --git a/backend/src/sync/eligibility/eligibility.service.spec.ts b/backend/src/sync/eligibility/eligibility.service.spec.ts index 9712bf7a..d645340e 100644 --- a/backend/src/sync/eligibility/eligibility.service.spec.ts +++ b/backend/src/sync/eligibility/eligibility.service.spec.ts @@ -548,6 +548,167 @@ describe('EligibilityService', () => { expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalledTimes(1) // batchUpsertRows only }) + const SOURCE_PLACEMENT_COLUMN_INDEX = 39 + const SOURCE_AGREEMENT_COLUMN_INDEX = 49 + + function upsertedColumnValues(index: number): unknown[] { + expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalled() + return mockPrisma.$executeRawUnsafe.mock.calls[0][index + 1] as unknown[] + } + + function makeOocIcmContact(overrides: Record = {}) { + return makeEligibleContact({ + misLegalAuthCode: 'OPC', + icmPlacements: [], + icmOrders: [], + icmAgreements: [ + { + rowId: 'AGR-OOC', + agreementType: 'Out of Care', + agreementStatus: 'Active', + agreementStartDate: '2024-01-01', + agreementEndDate: null, + terminationDate: null, + mcfdContract: 'C-OOC', + serviceProviderName: 'OOC Provider', + providerId: 'PROV-OOC', + }, + ], + csaStatus: null, + existingContactId: null, + ...overrides, + }) + } + + function makeOocMisFallbackContact(overrides: Record = {}) { + return makeEligibleContact({ + misLegalAuthCode: 'OPC', + icmPlacements: [], + icmOrders: [], + icmAgreements: [], + misPlacements: [ + { + type: 'PL', + status: 'ACTIVE', + startDate: '2024-01-01', + endDate: null, + contractNumber: 'CON-MIS', + placementNumber: 'MIS-PL-1', + serviceType: '54', + serviceProviderName: 'MIS Provider', + providerId: 'RE-1', + placeOfServiceName: 'Home', + }, + ], + misContracts: [ + { + contractNumber: 'CON-MIS', + providerId: 'RE-1', + type: 'Out of Care', + status: 'Active', + startDate: '2024-01-01', + endDate: null, + terminationDate: null, + serviceProviderName: 'MIS Provider', + }, + ], + misPayments: [], + csaStatus: null, + existingContactId: null, + ...overrides, + }) + } + + it('sets source_agreement from primaryAgreement when OOC has no placement', async () => { + mockPrisma.$queryRawUnsafe.mockResolvedValueOnce([makeOocIcmContact()]) + + await service.run(null) + + expect(upsertedColumnValues(SOURCE_PLACEMENT_COLUMN_INDEX)).toEqual([null]) + expect(upsertedColumnValues(SOURCE_AGREEMENT_COLUMN_INDEX)).toEqual(['ICM']) + }) + + it('sets source_agreement from MIS contract when OOC falls back to MIS', async () => { + mockPrisma.$queryRawUnsafe.mockResolvedValueOnce([makeOocMisFallbackContact()]) + + await service.run(null) + + expect(upsertedColumnValues(SOURCE_PLACEMENT_COLUMN_INDEX)).toEqual([null]) + expect(upsertedColumnValues(SOURCE_AGREEMENT_COLUMN_INDEX)).toEqual(['MIS']) + }) + + it('leaves placement and agreement source null when OOC has no agreement', async () => { + mockPrisma.$queryRawUnsafe.mockResolvedValueOnce([ + makeEligibleContact({ + misLegalAuthCode: 'OPT', + icmPlacements: [], + icmOrders: [], + icmAgreements: [], + csaStatus: null, + existingContactId: null, + }), + ]) + + await service.run(null) + + expect(upsertedColumnValues(SOURCE_PLACEMENT_COLUMN_INDEX)).toEqual([null]) + expect(upsertedColumnValues(SOURCE_AGREEMENT_COLUMN_INDEX)).toEqual([null]) + }) + + it('sets placement and agreement source from placement for non-OOC contacts', async () => { + mockPrisma.$queryRawUnsafe.mockResolvedValueOnce([makeEligibleContact()]) + + await service.run(null) + + expect(upsertedColumnValues(SOURCE_PLACEMENT_COLUMN_INDEX)).toEqual(['ICM']) + expect(upsertedColumnValues(SOURCE_AGREEMENT_COLUMN_INDEX)).toEqual(['ICM']) + }) + + it('sets source_agreement from MIS contract when ICM placement falls back to MIS agreement', async () => { + mockPrisma.$queryRawUnsafe.mockResolvedValueOnce([ + makeEligibleContact({ + icmPlacements: [ + { + type: 'Placement', + status: 'Active', + startDate: '2024-01-01', + endDate: null, + contractNumber: 'CON-1', + agreementRowId: 'AGR-MISSING', + paidUnpaid: 'Paid', + placementNumber: 'PL-1', + serviceType: 'Foster Care', + serviceProviderName: 'Provider A', + providerId: 'RE-1', + placeOfServiceName: 'Home A', + interruptedPlacementId: null, + }, + ], + icmAgreements: [], + icmOrders: [], + misContracts: [ + { + contractNumber: 'con-1', + providerId: 're-1', + type: 'MIS-CONTRACT', + status: 'Active', + startDate: '2024-01-01', + endDate: null, + terminationDate: null, + serviceProviderName: 'MIS Provider', + }, + ], + misPlacements: [], + misPayments: [], + }), + ]) + + await service.run(null) + + expect(upsertedColumnValues(SOURCE_PLACEMENT_COLUMN_INDEX)).toEqual(['ICM']) + expect(upsertedColumnValues(SOURCE_AGREEMENT_COLUMN_INDEX)).toEqual(['MIS']) + }) + it('should skip new contacts who are already over 18', async () => { mockPrisma.$queryRawUnsafe.mockResolvedValueOnce([makeOver18Contact()]) diff --git a/backend/src/sync/eligibility/eligibility.service.ts b/backend/src/sync/eligibility/eligibility.service.ts index 8f3418ef..cdc7ca0b 100644 --- a/backend/src/sync/eligibility/eligibility.service.ts +++ b/backend/src/sync/eligibility/eligibility.service.ts @@ -506,6 +506,11 @@ const CONTACT_COLUMNS: ContactColumnDef[] = [ extract: (row) => row.primaryAgreement?.mcfdContract ?? row.primaryPlacement?.contractNumber ?? null, }, + { + dbColumn: 'source_agreement', + pgType: 'text', + extract: (row) => row.primaryAgreement?.source ?? row.primaryPlacement?.source ?? null, + }, { dbColumn: 'order_number', pgType: 'text', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8dd21dd4..3fabd456 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2867,6 +2867,7 @@ function App() { agreementEndDate: contact.agreementEndDate ? formatDateYMD(contact.agreementEndDate) : '', terminationDate: contact.terminationDate ? formatDateYMD(contact.terminationDate) : '', mcfdContract: contact.mcfdContract || '', + sourceAgreement: contact.sourceAgreement || '', product: contact.product || '', isOver18: contact.isOver18 || false, cgwrks3: contact.holdBy || '', @@ -5984,7 +5985,7 @@ function App() { wordBreak: 'break-word', }} > - {childData.sourcePlacement ? ( + {childData.sourceAgreement ? ( - {childData.sourcePlacement} + {childData.sourceAgreement} ) : ( '-' diff --git a/frontend/src/service/contacts-service.ts b/frontend/src/service/contacts-service.ts index a868932e..0df236a8 100644 --- a/frontend/src/service/contacts-service.ts +++ b/frontend/src/service/contacts-service.ts @@ -61,6 +61,7 @@ export interface Contact { agreementEndDate?: string terminationDate?: string mcfdContract?: string + sourceAgreement?: string // Order fields orderNumber?: string orderType?: string diff --git a/frontend/src/utils/__tests__/mock-placement.test.ts b/frontend/src/utils/__tests__/mock-placement.test.ts index a0fd1d58..99454052 100644 --- a/frontend/src/utils/__tests__/mock-placement.test.ts +++ b/frontend/src/utils/__tests__/mock-placement.test.ts @@ -105,4 +105,20 @@ describe('mock placement helpers', () => { }) expect(formatDateYMD).toHaveBeenCalledTimes(2) }) + + test('hides placement source when placement location is blank', () => { + const result = buildPlacementDisplayValues( + { + placementLocation: '', + locationType: '', + locationSubType: '', + placementStatus: '', + sourcePlacement: 'MIS', + placeOfServiceName: '', + }, + vi.fn(), + ) + + expect(result.sourcePlacement).toBe('') + }) }) diff --git a/frontend/src/utils/mock-placement.ts b/frontend/src/utils/mock-placement.ts index 0029a206..7c71263b 100644 --- a/frontend/src/utils/mock-placement.ts +++ b/frontend/src/utils/mock-placement.ts @@ -38,11 +38,14 @@ export const isMockSection54Placement = (contact: MockPlacementMatchInput): bool normalizeMatchValue(contact.locationSubType) === '54' && normalizeMatchValue(contact.placementStatus) === 'ACTIVE' +const hasPlacementDetails = (contact: PlacementDisplayInput): boolean => + Boolean(contact.placementLocation?.trim()) + export const buildPlacementDisplayValues = ( contact: PlacementDisplayInput, formatDateYMD: (dateString: string) => string, ): PlacementDisplayValues => { - const hidePlacementDetails = isMockSection54Placement(contact) + const hidePlacementDetails = isMockSection54Placement(contact) || !hasPlacementDetails(contact) return { placementLocation: hidePlacementDetails ? '' : contact.placementLocation || '', diff --git a/migrations/sql/V24__add_source_agreement_to_contacts.sql b/migrations/sql/V24__add_source_agreement_to_contacts.sql new file mode 100644 index 00000000..8a014b6f --- /dev/null +++ b/migrations/sql/V24__add_source_agreement_to_contacts.sql @@ -0,0 +1,9 @@ +ALTER TABLE csa.contacts +ADD COLUMN IF NOT EXISTS source_agreement TEXT; + +COMMENT ON COLUMN csa.contacts.source_agreement IS 'ICM or MIS source for the primary agreement/contract displayed in CSA details'; + +UPDATE csa.contacts +SET source_agreement = source_placement +WHERE source_placement IS NOT NULL + AND source_agreement IS NULL;