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
4 changes: 4 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/api/main",
"job:data-ingestion": "node dist/jobs/entrypoints/data-ingestion",
"job:cra-file-transfer": "node dist/jobs/entrypoints/cra-file-transfer",
"job:cra-response-poll": "node dist/jobs/entrypoints/cra-response-poll",
"job:retry-failed": "node dist/jobs/entrypoints/retry-failed",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"make-badges": "istanbul-badges-readme --logo=vitest --exitCode=1",
Expand Down
25 changes: 24 additions & 1 deletion backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ model Contact {
akaLastName String @map("aka_last_name")
akaFirstName String @map("aka_first_name")
personIdIcm String @map("person_id_icm")
personIdIms String @map("person_id_ims")
personIdMis String @map("person_id_mis")
caseNumber String @map("case_number")
caseType String @map("case_type")
caseStatus String @map("case_status")
Expand Down Expand Up @@ -137,3 +137,26 @@ model TransferFile {
@@schema("csa")
@@map("transfer_files")
}

model JobRun {
id Int @id @default(autoincrement())
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")
error String?
metadata Json @default("{}") @db.JsonB
createdAt DateTime @default(now()) @map("created_at")
startedAt DateTime @map("started_at")
completedAt DateTime? @map("completed_at")

parentJob JobRun? @relation("ChildJobs", fields: [parentJobId], references: [id])
childJobs JobRun[] @relation("ChildJobs")

@@index([status])
@@index([parentJobId])
@@index([jobType, status])
@@schema("csa")
@@map("job_runs")
}
2 changes: 1 addition & 1 deletion backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ async function seedContacts() {
akaFirstName: faker.person.firstName(),

personIdIcm: faker.string.alphanumeric(10).toUpperCase(),
personIdIms: faker.string.alphanumeric(10).toUpperCase(),
personIdMis: faker.string.alphanumeric(10).toUpperCase(),

gender: faker.helpers.arrayElement(GENDERS),
dateOfBirth: birthDate,
Expand Down
2 changes: 1 addition & 1 deletion backend/src/api/contacts/contacts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export class ContactsService {
const data = await this.prisma.$queryRaw<ContactDto[]>`
SELECT id, last_name as "lastName", given_names as "givenNames", middle_name as "middleName",
aka_last_name as "akaLastName", aka_first_name as "akaFirstName",
person_id_icm as "personIdIcm", person_id_ims as "personIdIms",
person_id_icm as "personIdIcm", person_id_mis as "personIdMis",
gender, date_of_birth as "dateOfBirth", age,
case_number as "caseNumber", legacy_file_number as "legacyFileNumber",
case_type as "caseType", case_status as "caseStatus", case_load as "caseLoad",
Expand Down
2 changes: 1 addition & 1 deletion backend/src/api/contacts/dto/contact.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export class ContactDto {
personIdIcm: string

@ApiProperty({ description: 'IMS person identifier' })
personIdIms: string
personIdMis: string

@ApiProperty({ description: 'Primary case number associated with the contact' })
caseNumber: string
Expand Down
49 changes: 49 additions & 0 deletions backend/src/cra/cra.module.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { TestingModule } from '@nestjs/testing'
import { Test } from '@nestjs/testing'
import { JobType } from 'src/jobs/enums/job-type.enum'
import { JobRegistry } from 'src/jobs/job-registry.service'
import { JobsModule } from 'src/jobs/jobs.module'
import { CraModule } from './cra.module'
import { PollCraResponseHandler } from './handlers/poll-cra-response.handler'
import { SendCraFileHandler } from './handlers/send-cra-file.handler'

describe('CraModule', () => {
let module: TestingModule
let registry: JobRegistry

beforeEach(async () => {
module = await Test.createTestingModule({
imports: [JobsModule, CraModule],
}).compile()

registry = module.get<JobRegistry>(JobRegistry)
await module.init() // Trigger onModuleInit
})

afterEach(async () => {
await module.close()
})

it('should be defined', () => {
const craModule = module.get(CraModule)
expect(craModule).toBeDefined()
})

it('should register both CRA handlers', () => {
expect(registry.hasHandler(JobType.SEND_CRA_FILE)).toBe(true)
expect(registry.hasHandler(JobType.POLL_CRA_RESPONSE)).toBe(true)
})

it('should register handlers with correct types', () => {
const sendHandler = registry.getHandler(JobType.SEND_CRA_FILE)
const pollHandler = registry.getHandler(JobType.POLL_CRA_RESPONSE)

expect(sendHandler).toBeInstanceOf(SendCraFileHandler)
expect(pollHandler).toBeInstanceOf(PollCraResponseHandler)
})

it('should export all handler providers', () => {
expect(module.get(SendCraFileHandler)).toBeDefined()
expect(module.get(PollCraResponseHandler)).toBeDefined()
})
})
28 changes: 28 additions & 0 deletions backend/src/cra/cra.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Module, OnModuleInit } from '@nestjs/common'
import { JobRegistry } from 'src/jobs/job-registry.service'
import { JobsModule } from 'src/jobs/jobs.module'
import { PollCraResponseHandler } from './handlers/poll-cra-response.handler'
import { SendCraFileHandler } from './handlers/send-cra-file.handler'

/*
* Generates and sends files to CRA
* Polls and process response files from CRA
*/
@Module({
imports: [JobsModule],
providers: [SendCraFileHandler, PollCraResponseHandler],
exports: [SendCraFileHandler, PollCraResponseHandler],
})
export class CraModule implements OnModuleInit {
constructor(
private readonly registry: JobRegistry,
private readonly sendCraFileHandler: SendCraFileHandler,
private readonly pollCraResponseHandler: PollCraResponseHandler,
) {}

onModuleInit() {
// Register CRA-related job handlers
this.registry.register(this.sendCraFileHandler.jobType, this.sendCraFileHandler)
this.registry.register(this.pollCraResponseHandler.jobType, this.pollCraResponseHandler)
}
}
35 changes: 35 additions & 0 deletions backend/src/cra/handlers/poll-cra-response.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common'
import { BaseJob } from 'src/jobs/base-job'
import { JobType } from 'src/jobs/enums/job-type.enum'
import { JobResult } from 'src/jobs/interfaces/job-result.interface'
import { JobContext } from 'src/jobs/interfaces/job.interface'

/*
* Checks for response files from CRA and processes them
* Triggered by CronJob POLL_CRA_RESPONSE
*/
@Injectable()
export class PollCraResponseHandler extends BaseJob {
readonly jobType = JobType.POLL_CRA_RESPONSE

async execute(_context: JobContext): Promise<JobResult> {
// TODO: Implement CRA response polling
// 1. Poll CRA endpoint for response files
// 2. Download new response files
// 3. Parse and validate response data
// 4. Update contact records with CRA responses
// 5. Return metadata: { files_processed, records_updated, errors }

this.logger.log('POLL_CRA_RESPONSE stub - not yet implemented')

return {
success: true,
message: 'CRA response polling stub',
metadata: {
files_processed: 0,
records_updated: 0,
errors: [],
},
}
}
}
35 changes: 35 additions & 0 deletions backend/src/cra/handlers/send-cra-file.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common'
import { BaseJob } from 'src/jobs/base-job'
import { JobType } from 'src/jobs/enums/job-type.enum'
import { JobResult } from 'src/jobs/interfaces/job-result.interface'
import { JobContext } from 'src/jobs/interfaces/job.interface'

/*
* Triggered by CronJob SEND_CRA_FILE
* Creates a CRA-formatted file with eligible contacts and send it for tranfer
*/
@Injectable()
export class SendCraFileHandler extends BaseJob {
readonly jobType = JobType.SEND_CRA_FILE

async execute(_context: JobContext): Promise<JobResult> {
// TODO: Implement CRA file generation and transfer
// 1. Query eligible contacts
// 2. Format data according to CRA specifications
// 3. Write to file storage
// 4. Transfer file to CRA destination
// 5. Return metadata: { file_path, record_count, transfer_status }

this.logger.log('SEND_CRA_FILE stub - not yet implemented')

return {
success: true,
message: 'CRA file send stub',
metadata: {
file_path: null,
record_count: 0,
transfer_status: 'pending',
},
}
}
}
31 changes: 31 additions & 0 deletions backend/src/jobs/base-job.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Logger } from '@nestjs/common'
import { JobType } from './enums/job-type.enum'
import { JobResult } from './interfaces/job-result.interface'
import { Job, JobContext } from './interfaces/job.interface'

export abstract class BaseJob implements Job {
protected readonly logger: Logger

abstract readonly jobType: JobType
readonly inlineRetryAttempts: number = 2

constructor() {
this.logger = new Logger(this.constructor.name)
}

abstract execute(context: JobContext): Promise<JobResult>

async onStart(context: JobContext): Promise<void> {
this.logger.log(`Starting job ${context.jobRunId} [${this.jobType}]`)
}

async onSuccess(context: JobContext, result: JobResult): Promise<void> {
this.logger.log(`Job ${context.jobRunId} completed successfully: ${result.message ?? 'OK'}`)
}

async onFailure(context: JobContext, error: Error): Promise<void> {
this.logger.warn(
`Job ${context.jobRunId} failed (attempt ${context.retryCount + 1}): ${error.message}`,
)
}
}
42 changes: 42 additions & 0 deletions backend/src/jobs/entrypoints/cra-file-transfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env node
import { Logger } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import { CraModule } from 'src/cra/cra.module'
import { JobTrigger } from '../enums/job-trigger.enum'
import { JobType } from '../enums/job-type.enum'
import { JobRunner } from '../job-runner.service'

// Generates and sends CRA file
async function bootstrap() {
const logger = new Logger('CRAFileTransferJob')

try {
logger.log('Bootstrapping CRA file transfer job...')

// Create NestJS application context (no HTTP server)
const app = await NestFactory.createApplicationContext(CraModule, {
logger: ['log', 'error', 'warn'],
})

// Get JobRunner from DI container
const jobRunner = app.get(JobRunner)

// Run SEND_CRA_FILE job
const result = await jobRunner.runJobType(JobType.SEND_CRA_FILE, JobTrigger.CRON)

await app.close()

if (result.success) {
logger.log('CRA file transfer completed successfully')
process.exit(0)
} else {
logger.error(`CRA file transfer failed: ${result.message}`)
process.exit(1)
}
} catch (error) {
logger.error(`Fatal error: ${error.message}`, error.stack)
process.exit(1)
}
}

bootstrap()
42 changes: 42 additions & 0 deletions backend/src/jobs/entrypoints/cra-response-poll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env node
import { Logger } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import { CraModule } from 'src/cra/cra.module'
import { JobTrigger } from '../enums/job-trigger.enum'
import { JobType } from '../enums/job-type.enum'
import { JobRunner } from '../job-runner.service'

// Polls for and processes CRA response files
async function bootstrap() {
const logger = new Logger('CRAResponsePollJob')

try {
logger.log('Bootstrapping CRA response poll job...')

// Create NestJS application context (no HTTP server)
const app = await NestFactory.createApplicationContext(CraModule, {
logger: ['log', 'error', 'warn'],
})

// Get JobRunner from DI container
const jobRunner = app.get(JobRunner)

// Run POLL_CRA_RESPONSE job
const result = await jobRunner.runJobType(JobType.POLL_CRA_RESPONSE, JobTrigger.CRON)

await app.close()

if (result.success) {
logger.log('CRA response poll completed successfully')
process.exit(0)
} else {
logger.error(`CRA response poll failed: ${result.message}`)
process.exit(1)
}
} catch (error) {
logger.error(`Fatal error: ${error.message}`, error.stack)
process.exit(1)
}
}

bootstrap()
42 changes: 42 additions & 0 deletions backend/src/jobs/entrypoints/data-ingestion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env node
import { Logger } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import { SyncModule } from 'src/sync/sync.module'
import { JobTrigger } from '../enums/job-trigger.enum'
import { JobType } from '../enums/job-type.enum'
import { JobRunner } from '../job-runner.service'

// Orchestrates: INGEST_ICM, INGEST_MIS, RUN_ELIGIBILITY, SYNC_ICM
async function bootstrap() {
const logger = new Logger('DataIngestionJob')

try {
logger.log('Bootstrapping data ingestion job...')

// Create NestJS application context (no HTTP server)
const app = await NestFactory.createApplicationContext(SyncModule, {
logger: ['log', 'error', 'warn'],
})

// Get JobRunner from DI container
const jobRunner = app.get(JobRunner)

// Run INGEST_DATA job
const result = await jobRunner.runJobType(JobType.INGEST_DATA, JobTrigger.CRON)

await app.close()

if (result.success) {
logger.log('Data ingestion completed successfully')
process.exit(0)
} else {
logger.error(`Data ingestion failed: ${result.message}`)
process.exit(1)
}
} catch (error) {
logger.error(`Fatal error: ${error.message}`, error.stack)
process.exit(1)
}
}

bootstrap()
Loading