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
23 changes: 21 additions & 2 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -216,19 +216,38 @@ model JobRun {
jobType String @map("job_type")
status String
parentJobId Int? @map("parent_job_id")
jobTrigger String @map("job_trigger")
retryCount Int @default(0) @map("retry_count")
jobTrigger String @map("job_trigger")
triggeredByUser String? @map("triggered_by_user")
retryCount Int @default(0) @map("retry_count")
error String?
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
startedAt DateTime @map("started_at") @db.Timestamptz()
completedAt DateTime? @map("completed_at") @db.Timestamptz()
parentJob JobRun? @relation("ChildJobs", fields: [parentJobId], references: [id])
childJobs JobRun[] @relation("ChildJobs")
activities JobActivity[]

@@index([status])
@@index([parentJobId])
@@index([jobType, status])
@@index([createdAt(sort: Desc)])
@@map("job_runs")
@@schema("csa")
}

model JobActivity {
id Int @id @default(autoincrement())
jobRunId Int? @map("job_run_id")
when DateTime @default(now()) @db.Timestamptz()
severity String
type String
related String?
jobRun JobRun? @relation(fields: [jobRunId], references: [id], onDelete: SetNull)

@@index([jobRunId, when(sort: Desc)])
@@index([severity])
@@index([type])
@@map("job_activities")
@@schema("csa")
}
31 changes: 31 additions & 0 deletions backend/src/api/batches/batches.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,37 @@ describe('BatchesService', () => {

expectIncomplete(result, 104, ['Cancellation End Date', 'Cancellation Reason Code'])
})

it('should tag manual add incomplete validation with BATCH warn metadata', async () => {
const contactMissingProvince = makeContact({
id: 102,
caseNumber: 'CASE-102',
birthProvince: null,
})

setupCommonMocks([contactMissingProvince])
mockPrisma.batch.update.mockResolvedValue({
id: 1,
batchNumber: 1,
status: 'pending',
recordCount: 0,
batchDate: null,
createdAt: new Date(),
systemComments: null,
})
const warnSpy = vi.spyOn(service['logger'], 'warn').mockImplementation(() => {})

await service.addContactsToPendingBatch([102], 'jsmith')

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Manual add to batch by jsmith'),
expect.objectContaining({
activityType: 'BATCH',
related: expect.stringContaining('skipped due to missing CRA mandatory fields'),
}),
)
warnSpy.mockRestore()
})
})

describe('User Story 40101 - S2: Auto-batch with CRA validation & auto-hold', () => {
Expand Down
55 changes: 53 additions & 2 deletions backend/src/api/batches/batches.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import { AppLogger } from 'src/common/logger/app-logger'
import { JobActivityType } from 'src/jobs/enums/job-activity-type.enum'
import { Prisma } from '@prisma/client'
import { PrismaService } from 'src/common/database/prisma.service'
import {
Expand Down Expand Up @@ -87,7 +89,7 @@ export interface UpdateBatchStatusOptions {

@Injectable()
export class BatchesService {
private readonly logger = new Logger(BatchesService.name)
private readonly logger = new AppLogger(BatchesService.name)

constructor(
private prisma: PrismaService,
Expand All @@ -104,6 +106,44 @@ export class BatchesService {
})
}

private logBatchOperationIssues(
operation: 'add' | 'remove',
userId: string,
batchId: number,
result: Pick<BatchOperationResult, 'skipped' | 'incomplete'>,
): void {
const batchLabel = `batch ${batchId}`
const isAutoBatch = userId === 'SYSTEM'
const trigger = isAutoBatch ? 'Auto-batch' : `Manual ${operation} to batch by ${userId}`

if (result.incomplete.length > 0) {
const count = result.incomplete.length
const detail = isAutoBatch
? `${count} contacts auto-held due to missing CRA mandatory fields`
: `${count} contacts skipped due to missing CRA mandatory fields`
this.logger.warn(`${trigger}: ${detail} (${batchLabel})`, {
activityType: JobActivityType.BATCH,
related: `${detail} (${batchLabel})`,
})
}

const errors = result.skipped.filter((entry) => entry.reason === 'error')
if (errors.length > 0) {
this.logger.error(`${trigger}: ${errors.length} contacts failed (${batchLabel})`, {
activityType: JobActivityType.BATCH,
related: `${errors.length} contacts failed during ${operation} (${batchLabel})`,
})
}

const otherSkipped = result.skipped.filter((entry) => entry.reason !== 'error')
if (otherSkipped.length > 0) {
this.logger.warn(`${trigger}: ${otherSkipped.length} contacts skipped (${batchLabel})`, {
activityType: JobActivityType.BATCH,
related: `${otherSkipped.length} contacts skipped during ${operation} (${batchLabel})`,
})
}
}

private async nextBatchNumber(tx: Prisma.TransactionClient): Promise<number> {
await tx.$executeRaw(
Prisma.sql`SELECT pg_advisory_xact_lock(${BATCH_ADVISORY_LOCK_CLASS}, ${BATCH_NUMBER_ADVISORY_LOCK_OBJECT})`,
Expand Down Expand Up @@ -566,6 +606,8 @@ export class BatchesService {
}),
)

this.logBatchOperationIssues('add', userId, result.batch.id, result)

return result
}

Expand Down Expand Up @@ -712,6 +754,13 @@ export class BatchesService {
)

if (!transition.success) {
this.logger.error(
`Manual remove from batch failed for contact ${contactId}: ${transition.reason}`,
{
activityType: JobActivityType.BATCH,
related: `Manual remove from batch contact ${contactId} by ${userId ?? 'unknown'}: ${transition.reason}`,
},
)
throw new BadRequestException(
`Failed to transition contact ${contactId} on REMOVE_FROM_BATCH: ${transition.reason}`,
)
Expand Down Expand Up @@ -805,6 +854,8 @@ export class BatchesService {
}),
)

this.logBatchOperationIssues('remove', userId, result.batch.id, result)

return result
}

Expand Down
14 changes: 13 additions & 1 deletion backend/src/api/contacts/contacts.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,19 @@ describe('ContactsController', () => {
const res = await request(app.getHttpServer()).post('/contacts/1/run-eligibility').expect(200)

expect(res.body).toEqual(result)
expect(service.runContactEligibility).toHaveBeenCalledWith(1)
expect(service.runContactEligibility).toHaveBeenCalledWith(1, 'SYSTEM')
})

it('should pass username from @CurrentUser when guard sets it', async () => {
const result = { previousStatus: 'eligible', newStatus: 'in_pay' }
vi.spyOn(service, 'runContactEligibility').mockResolvedValue(result)

await request(app.getHttpServer())
.post('/contacts/1/run-eligibility')
.set('x-test-username', 'jsmith')
.expect(200)

expect(service.runContactEligibility).toHaveBeenCalledWith(1, 'jsmith')
})

it('should return 404 when contact not found', async () => {
Expand Down
4 changes: 2 additions & 2 deletions backend/src/api/contacts/contacts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,8 @@ export class ContactsController {
@ApiResponse({ status: 200, description: 'Eligibility result with previous and new status' })
@ApiResponse({ status: 404, description: 'Contact not found' })
@ApiResponse({ status: 422, description: 'Contact not found in staging tables' })
async runEligibility(@Param('id', ParseIntPipe) id: number) {
return this.contactsService.runContactEligibility(id)
async runEligibility(@Param('id', ParseIntPipe) id: number, @CurrentUser() userId: string) {
return this.contactsService.runContactEligibility(id, userId)
}

@Patch(':id/review-flag')
Expand Down
43 changes: 33 additions & 10 deletions backend/src/api/contacts/contacts.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1714,8 +1714,10 @@ describe('ContactsService', () => {
it('should throw NotFoundException when contact does not exist', async () => {
vi.spyOn(prisma.contact, 'findUnique').mockResolvedValue(null)

await expect(service.runContactEligibility(999)).rejects.toThrow(NotFoundException)
await expect(service.runContactEligibility(999)).rejects.toThrow('Contact 999 not found')
await expect(service.runContactEligibility(999, 'JSMITH')).rejects.toThrow(NotFoundException)
await expect(service.runContactEligibility(999, 'JSMITH')).rejects.toThrow(
'Contact 999 not found',
)
})

it('should map EligibilityInputError to UnprocessableEntityException', async () => {
Expand All @@ -1724,11 +1726,23 @@ describe('ContactsService', () => {
eligibility.runForContact = vi
.fn()
.mockRejectedValue(new EligibilityInputError('Contact ICM-1 not found in staging tables'))
const errorSpy = vi.spyOn(service['logger'], 'error').mockImplementation(() => {})

await expect(service.runContactEligibility(1)).rejects.toThrow(UnprocessableEntityException)
await expect(service.runContactEligibility(1)).rejects.toThrow(
await expect(service.runContactEligibility(1, 'JSMITH')).rejects.toThrow(
UnprocessableEntityException,
)
await expect(service.runContactEligibility(1, 'JSMITH')).rejects.toThrow(
'Contact ICM-1 not found in staging tables',
)
expect(errorSpy).toHaveBeenCalledWith(
'Manual eligibility failed for contact 1: Contact ICM-1 not found in staging tables',
{
activityType: 'DATA_QUALITY',
related:
'Manual eligibility contact 1 (ICM-1) by JSMITH: Contact ICM-1 not found in staging tables',
},
)
errorSpy.mockRestore()
})

it('should propagate generic Errors without wrapping (becomes 500 at HTTP layer)', async () => {
Expand All @@ -1737,9 +1751,9 @@ describe('ContactsService', () => {
const dbError = new Error('connection terminated unexpectedly')
eligibility.runForContact = vi.fn().mockRejectedValue(dbError)

await expect(service.runContactEligibility(1)).rejects.toBe(dbError)
await expect(service.runContactEligibility(1, 'JSMITH')).rejects.toBe(dbError)
// Specifically must NOT have been rewrapped as a 422
await expect(service.runContactEligibility(1)).rejects.not.toBeInstanceOf(
await expect(service.runContactEligibility(1, 'JSMITH')).rejects.not.toBeInstanceOf(
UnprocessableEntityException,
)
})
Expand All @@ -1751,7 +1765,7 @@ describe('ContactsService', () => {
.fn()
.mockResolvedValue({ previousStatus: 'eligible', newStatus: 'in_pay' })

await expect(service.runContactEligibility(1)).resolves.toEqual({
await expect(service.runContactEligibility(1, 'JSMITH')).resolves.toEqual({
previousStatus: 'eligible',
newStatus: 'in_pay',
})
Expand All @@ -1766,7 +1780,7 @@ describe('ContactsService', () => {
.mockResolvedValue({ previousStatus: 'eligible', newStatus: 'in_pay' })
const icmSync = vi.spyOn(service['icmSyncBackService'], 'syncSingleContact')

await service.runContactEligibility(1)
await service.runContactEligibility(1, 'JSMITH')

expect(icmSync).toHaveBeenCalledWith(1)
})
Expand All @@ -1779,7 +1793,7 @@ describe('ContactsService', () => {
.mockResolvedValue({ previousStatus: 'eligible', newStatus: 'eligible' })
const icmSync = vi.spyOn(service['icmSyncBackService'], 'syncSingleContact')

await service.runContactEligibility(1)
await service.runContactEligibility(1, 'JSMITH')

expect(icmSync).not.toHaveBeenCalled()
})
Expand All @@ -1793,11 +1807,20 @@ describe('ContactsService', () => {
vi.spyOn(service['icmSyncBackService'], 'syncSingleContact').mockRejectedValue(
new Error('ICM down'),
)
const warnSpy = vi.spyOn(service['logger'], 'warn').mockImplementation(() => {})

await expect(service.runContactEligibility(1)).resolves.toEqual({
await expect(service.runContactEligibility(1, 'JSMITH')).resolves.toEqual({
previousStatus: 'eligible',
newStatus: 'in_pay',
})

await vi.waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith('Immediate ICM sync failed for contact 1: ICM down', {
activityType: 'ICM',
related: 'ICM sync failed after manual eligibility contact 1 by JSMITH',
})
})
warnSpy.mockRestore()
})
})
})
14 changes: 12 additions & 2 deletions backend/src/api/contacts/contacts.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
UnprocessableEntityException,
} from '@nestjs/common'
import { AppLogger } from 'src/common/logger/app-logger'
import { JobActivityType } from 'src/jobs/enums/job-activity-type.enum'
import { PaginatedResponse } from 'src/api/common/dto/paginated-response.dto'
import { PrismaService } from 'src/common/database/prisma.service'
import {
Expand Down Expand Up @@ -36,7 +37,7 @@ import type {

@Injectable()
export class ContactsService {
private readonly logger = new Logger(ContactsService.name)
private readonly logger = new AppLogger(ContactsService.name)

constructor(
private prisma: PrismaService,
Expand Down Expand Up @@ -829,6 +830,7 @@ export class ContactsService {

async runContactEligibility(
contactId: number,
triggeredByUser: string,
): Promise<{ previousStatus: string | null; newStatus: string }> {
const contact = await this.prisma.contact.findUnique({
where: { id: contactId },
Expand All @@ -844,6 +846,10 @@ export class ContactsService {
result = await this.eligibilityService.runForContact(contact.personIdIcm)
} catch (err) {
if (err instanceof EligibilityInputError) {
this.logger.error(`Manual eligibility failed for contact ${contactId}: ${err.message}`, {
activityType: JobActivityType.DATA_QUALITY,
related: `Manual eligibility contact ${contactId} (${contact.personIdIcm}) by ${triggeredByUser}: ${err.message}`,
})
throw new UnprocessableEntityException(err.message)
}
throw err
Expand All @@ -866,6 +872,10 @@ export class ContactsService {
this.icmSyncBackService.syncSingleContact(contactId).catch((err) => {
this.logger.warn(
`Immediate ICM sync failed for contact ${contactId}: ${(err as Error).message}`,
{
activityType: JobActivityType.ICM,
related: `ICM sync failed after manual eligibility contact ${contactId} by ${triggeredByUser}`,
},
)
})
}
Expand Down
Loading
Loading