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
17 changes: 10 additions & 7 deletions backend/src/api/batches/batches.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ export class BatchesService {
) {}

async findAll() {
return this.prisma.batch.findMany({
const batches = await this.prisma.batch.findMany({
orderBy: { createdAt: 'desc' },
})
return batches.map(enrichLabels)
}

async findOne(id: number) {
Expand All @@ -52,7 +53,7 @@ export class BatchesService {
if (!batch) {
throw new NotFoundException(`Batch ${id} not found`)
}
return batch
return enrichLabels(batch)
}

// Update a batch's status using the state machine.
Expand Down Expand Up @@ -151,10 +152,12 @@ export class BatchesService {
orderBy: { createdAt: 'desc' },
})

return details.map((detail) => ({
...detail,
contact: enrichLabels(detail.contact),
}))
return details.map((detail) =>
enrichLabels({
...detail,
contact: enrichLabels(detail.contact),
}),
)
}

async findOrCreatePendingBatch() {
Expand All @@ -173,7 +176,7 @@ export class BatchesService {
})
}

return pendingBatch
return enrichLabels(pendingBatch)
}

async addContactsToPendingBatch(
Expand Down
3 changes: 3 additions & 0 deletions backend/src/api/batches/dto/batch.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export class BatchDto {
@ApiProperty()
status: string

@ApiProperty({ description: 'Display label for batch status' })
statusLabel: string

@ApiProperty()
recordCount: number

Expand Down
14 changes: 10 additions & 4 deletions backend/src/api/batches/dto/batches.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ describe('BatchesService', () => {

const result = await service.findAll()

expect(result).toEqual(batches)
expect(result).toEqual([
{ id: 2, status: 'pending', createdAt: new Date('2026-01-29'), statusLabel: 'Pending' },
{ id: 1, status: 'processed', createdAt: new Date('2026-01-28'), statusLabel: 'Processed' },
])
expect(mockPrismaService.batch.findMany).toHaveBeenCalledWith({
orderBy: { createdAt: 'desc' },
})
Expand All @@ -76,7 +79,7 @@ describe('BatchesService', () => {

const result = await service.findOne(1)

expect(result).toEqual(batch)
expect(result).toEqual({ ...batch, statusLabel: 'Pending' })
expect(mockPrismaService.batch.findUnique).toHaveBeenCalledWith({
where: { id: 1 },
})
Expand All @@ -98,6 +101,8 @@ describe('BatchesService', () => {
id: 1,
contactId: 100,
batchId: 1,
transactionType: 'application',
status: 'pending',
contact: {
id: 100,
lastName: 'Doe',
Expand All @@ -115,6 +120,7 @@ describe('BatchesService', () => {
expect(result).toEqual(
details.map((d) => ({
...d,
statusLabel: 'Pending',
contact: {
...d.contact,
csaStatusLabel: 'Eligible',
Expand Down Expand Up @@ -152,7 +158,7 @@ describe('BatchesService', () => {

const result = await service.findOrCreatePendingBatch()

expect(result).toEqual(pendingBatch)
expect(result).toEqual({ ...pendingBatch, statusLabel: 'Pending' })
expect(mockPrismaService.batch.findFirst).toHaveBeenCalledWith({
where: { status: BATCH_STATUS.PENDING },
})
Expand All @@ -166,7 +172,7 @@ describe('BatchesService', () => {

const result = await service.findOrCreatePendingBatch()

expect(result).toEqual(newBatch)
expect(result).toEqual({ ...newBatch, statusLabel: 'Pending' })
expect(mockPrismaService.batch.create).toHaveBeenCalledWith({
data: {
batchDate: null,
Expand Down
6 changes: 6 additions & 0 deletions backend/src/api/batches/dto/contact-batch-detail.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export class BatchSummaryDto {

@ApiProperty()
status: string

@ApiProperty({ description: 'Display label for batch status' })
statusLabel: string
}

export class ContactSummaryDto {
Expand Down Expand Up @@ -61,6 +64,9 @@ export class ContactBatchDetailDto {

@ApiProperty({ nullable: true })
status: string | null

@ApiProperty({ description: 'Display label for batch detail status' })
statusLabel: string
}

export class ContactBatchDetailWithContactDto extends ContactBatchDetailDto {
Expand Down
8 changes: 7 additions & 1 deletion backend/src/api/contacts/contacts.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1289,7 +1289,13 @@ describe('ContactsService', () => {

const result = await service.findContactBatches(1)

expect(result).toEqual(batchDetails)
expect(result).toEqual([
{
...batchDetails[0],
statusLabel: 'Processed',
batch: { ...batchDetails[0].batch, statusLabel: 'Processed' },
},
])
expect(prisma.contactBatchDetail.findMany).toHaveBeenCalledWith({
where: { contactId: 1 },
include: {
Expand Down
9 changes: 8 additions & 1 deletion backend/src/api/contacts/contacts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ export class ContactsService {
throw new NotFoundException(`Contact ${contactId} not found`)
}

return this.prisma.contactBatchDetail.findMany({
const details = await this.prisma.contactBatchDetail.findMany({
where: { contactId },
include: {
batch: {
Expand All @@ -590,6 +590,13 @@ export class ContactsService {
},
orderBy: { createdAt: 'desc' },
})

return details.map((detail) =>
enrichLabels({
...detail,
batch: enrichLabels(detail.batch),
}),
)
}

// Escape ILIKE special characters to prevent wildcard injection
Expand Down
16 changes: 15 additions & 1 deletion backend/src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { DateTime } from 'luxon'
import { CSA_STATUS_LABELS } from './state-machine/constants'
import {
BATCH_DETAIL_STATUS_LABELS,
BATCH_STATUS_LABELS,
CSA_STATUS_LABELS,
} from './state-machine/constants'

// Date Helpers

Expand Down Expand Up @@ -78,6 +82,16 @@ export function enrichLabels<T extends Record<string, any>>(record: T): T {
labels.csaStatusLabel = ''
}

if ('status' in record && record.status) {
if ('transactionType' in record) {
labels.statusLabel = BATCH_DETAIL_STATUS_LABELS[record.status] ?? record.status
} else {
labels.statusLabel = BATCH_STATUS_LABELS[record.status] ?? record.status
}
} else if ('status' in record) {
labels.statusLabel = ''
}

const flags: Record<string, boolean> = {}

if ('dateOfBirth' in record && record.dateOfBirth) {
Expand Down
31 changes: 28 additions & 3 deletions backend/src/sync/eligibility/eligibility.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,22 @@ const CHANGED_CONTACTS_CTE = `
* - MIS contracts join via service_provider_id on placements
* - MIS payments join via contract_number on contracts
*/
export function buildLoadContactProfilesSql(threshold: Date | null): {
export function buildLoadContactProfilesSql(
threshold: Date | null,
agedOutContactIds?: string[],
): {
sql: string
params: unknown[]
} {
const isIncremental = threshold !== null
const hasAgedOut = isIncremental && agedOutContactIds && agedOutContactIds.length > 0

let eligibleCasesFilter = ''
if (isIncremental) {
eligibleCasesFilter = hasAgedOut
? 'WHERE cases.CONTACT_ROW_ID IN (SELECT CONTACT_ROW_ID FROM changed_contacts) OR cases.CONTACT_ROW_ID = ANY($2::TEXT[])'
: 'WHERE cases.CONTACT_ROW_ID IN (SELECT CONTACT_ROW_ID FROM changed_contacts)'
}

const sql = `
WITH${isIncremental ? CHANGED_CONTACTS_CTE : ''}
Expand All @@ -136,7 +147,7 @@ export function buildLoadContactProfilesSql(threshold: Date | null): {
cases.X_LEGACY_FILE_NUM,
cases.PERSON_ID_MIS
FROM stg_icm_cases cases
${isIncremental ? 'WHERE cases.CONTACT_ROW_ID IN (SELECT CONTACT_ROW_ID FROM changed_contacts)' : ''}
${eligibleCasesFilter}
),

latest_legal_auth AS (
Expand Down Expand Up @@ -393,6 +404,20 @@ export function buildLoadContactProfilesSql(threshold: Date | null): {

return {
sql,
params: isIncremental ? [threshold] : [],
params: hasAgedOut ? [threshold, agedOutContactIds] : isIncremental ? [threshold] : [],
}
}

export function buildFindAgedOutContactIdsSql(cutoffDate: Date): {
sql: string
params: [Date]
} {
const sql = `
SELECT person_id_icm
FROM csa.contacts
WHERE csa_status IN ('eligible', 'in_pay', 'not_eligible_out_of_pay')
AND date_of_birth IS NOT NULL
AND date_of_birth < $1
`
return { sql, params: [cutoffDate] }
}
79 changes: 78 additions & 1 deletion backend/src/sync/eligibility/eligibility.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'
import { PrismaService } from 'src/common/database/prisma.service'
import { JobsService } from 'src/jobs/jobs.service'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildLoadContactProfilesSql } from './eligibility.queries'
import { buildFindAgedOutContactIdsSql, buildLoadContactProfilesSql } from './eligibility.queries'
import { EligibilityService } from './eligibility.service'

describe('EligibilityService', () => {
Expand Down Expand Up @@ -228,6 +228,47 @@ describe('EligibilityService', () => {
)
})

it('should query for aged-out contacts in incremental mode', async () => {
const lastSuccess = new Date('2026-02-14T10:00:00Z')
mockJobsService.getLastSuccessTimestamp.mockResolvedValue(lastSuccess)

await service.run()

expect(mockPrisma.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('csa_status IN'),
expect.any(Date),
)
})

it('should not query for aged-out contacts in full load mode', async () => {
mockJobsService.getLastSuccessTimestamp.mockResolvedValue(null)

await service.run()

expect(mockPrisma.$queryRawUnsafe).not.toHaveBeenCalledWith(
expect.stringContaining('csa_status IN'),
expect.any(Date),
)
})

it('should include aged-out IDs in profile query when found', async () => {
const lastSuccess = new Date('2026-02-14T10:00:00Z')
mockJobsService.getLastSuccessTimestamp.mockResolvedValue(lastSuccess)

mockPrisma.$queryRawUnsafe
.mockResolvedValueOnce([{ person_id_icm: 'AGED-1' }, { person_id_icm: 'AGED-2' }]) // aged-out query
.mockResolvedValueOnce([]) // loadContactProfiles

await service.run()

const expectedThreshold = new Date(lastSuccess.getTime() - 2 * 24 * 60 * 60 * 1000)
expect(mockPrisma.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('ANY($2::TEXT[])'),
expectedThreshold,
['AGED-1', 'AGED-2'],
)
})

// Helpers

//contact that reaches step 7->eligible (under 18, valid placement + order)
Expand Down Expand Up @@ -540,4 +581,40 @@ describe('buildLoadContactProfilesSql', () => {
expect(sql).toContain('legal_auth.PAR_ROW_ID = cases.CONTACT_ROW_ID')
expect(sql).not.toContain('legal_auth.PAR_ROW_ID = cases.ROW_ID')
})

it('should include ANY clause when agedOutContactIds provided in incremental mode', () => {
const { sql, params } = buildLoadContactProfilesSql(new Date('2026-02-12'), ['ICM-1', 'ICM-2'])
expect(sql).toContain('ANY($2::TEXT[])')
expect(sql).toContain('changed_contacts')
expect(params).toEqual([new Date('2026-02-12'), ['ICM-1', 'ICM-2']])
})

it('should not include ANY clause when agedOutContactIds is empty', () => {
const { sql, params } = buildLoadContactProfilesSql(new Date('2026-02-12'), [])
expect(sql).not.toContain('ANY($2::TEXT[])')
expect(sql).toContain('changed_contacts')
expect(params).toEqual([new Date('2026-02-12')])
})

it('should ignore agedOutContactIds in full load mode', () => {
const { sql, params } = buildLoadContactProfilesSql(null, ['ICM-1'])
expect(sql).not.toContain('ANY')
expect(sql).not.toContain('changed_contacts')
expect(params).toEqual([])
})
})

describe('buildFindAgedOutContactIdsSql', () => {
it('should query contacts with transitionable statuses and DOB before cutoff', () => {
const cutoff = new Date('2008-03-01')
const { sql, params } = buildFindAgedOutContactIdsSql(cutoff)

expect(sql).toContain('csa_status IN')
expect(sql).toContain("'eligible'")
expect(sql).toContain("'in_pay'")
expect(sql).toContain("'not_eligible_out_of_pay'")
expect(sql).toContain('date_of_birth < $1')
expect(sql).toContain('date_of_birth IS NOT NULL')
expect(params).toEqual([cutoff])
})
})
Loading