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
3 changes: 3 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ DATABASE_URL=
FILE_TRANSFER_SERVICE_URL=http://localhost:4000
FILE_STORAGE_PATH=/tmp
CRA_ENVIRONMENT=test
CRA_USER_ID=TST0016
CRA_INTEGRATION_ENABLED=false
ENABLE_SWAGGER=true

# ICM API / Auth
ICM_API_URL=http://localhost:8080
ICM_TRUSTED_USERNAME=admin
ICM_API_USERNAME=
ICM_TOKEN_URL=http://localhost:8180/realms/icm/protocol/openid-connect/token
ICM_CLIENT_ID=csa-backend
ICM_CLIENT_SECRET=client-secret
Expand Down
31 changes: 23 additions & 8 deletions backend/src/api/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,11 @@ export class AdminService {
let hasCSAResponsibility = false
let icmResponsibilityName: string | undefined

if (icmData?.items?.Responsibility && Array.isArray(icmData.items.Responsibility)) {
const responsibilities = icmData.items.Responsibility
if (icmData?.items?.Responsibility) {
// Normalize to array - ICM returns object for single item, array for multiple
const responsibilities = Array.isArray(icmData.items.Responsibility)
? icmData.items.Responsibility
: [icmData.items.Responsibility]
const rwResponsibility = responsibilities.find((r) => r.Name === 'ICM CSA Application - RW')
const roResponsibility = responsibilities.find((r) => r.Name === 'ICM CSA Application - RO')

Expand Down Expand Up @@ -346,15 +349,21 @@ export class AdminService {

this.logger.log('Requesting ICM API with username:', username)

this.logger.log(
'ICM Username as per config:',
this.configService.get<string>('admin.icmUsername'),
)

const icmApiUrl = this.configService.get<string>('admin.icmApiUrl')!
const icmTrustedUsername = this.configService.get<string>('admin.icmTrustedUsername')!
const icmApiUsername = this.configService.get<string>('admin.icmUsername') || username

// Build QueryHierarchy parameter
const queryHierarchy = {
Employee: {
fields: 'Login Name, Party Name',
searchspec: "[Login Name] = 'UKHAN'",
// searchspec: `[Login Name] = '${username}'`,
// searchspec: "[Login Name] = 'UKHAN'",
searchspec: `[Login Name] = '${icmApiUsername}'`,
Responsibility: {
fields: 'Name',
searchspec:
Expand Down Expand Up @@ -425,8 +434,11 @@ export class AdminService {
]

// Check if user has ICM CSA Application responsibility
if (icmData?.items?.Responsibility && Array.isArray(icmData.items.Responsibility)) {
const responsibilities = icmData.items.Responsibility
if (icmData?.items?.Responsibility) {
// Normalize to array - ICM returns object for single item, array for multiple
const responsibilities = Array.isArray(icmData.items.Responsibility)
? icmData.items.Responsibility
: [icmData.items.Responsibility]
const hasRWAccess = responsibilities.some((r) => r.Name === 'ICM CSA Application - RW')
const hasROAccess = responsibilities.some((r) => r.Name === 'ICM CSA Application - RO')

Expand Down Expand Up @@ -470,8 +482,11 @@ export class AdminService {
private extractResponsibilitiesFromICMData(icmData: ICMEmployeeResponse): string[] {
const responsibilities = ['user'] // Default responsibility

if (icmData?.items?.Responsibility && Array.isArray(icmData.items.Responsibility)) {
const icmResponsibilities = icmData.items.Responsibility
if (icmData?.items?.Responsibility) {
// Normalize to array - ICM returns object for single item, array for multiple
const icmResponsibilities = Array.isArray(icmData.items.Responsibility)
? icmData.items.Responsibility
: [icmData.items.Responsibility]
const hasRWAccess = icmResponsibilities.some((r) => r.Name === 'ICM CSA Application - RW')
const hasROAccess = icmResponsibilities.some((r) => r.Name === 'ICM CSA Application - RO')

Expand Down
2 changes: 1 addition & 1 deletion backend/src/api/api.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { MetricsController } from './metrics/metrics.controller'
import { MockModule } from './mock/mock.module'
import { StatusUpdateModule } from './status_update/status-update.module'

const enableMockApi = process.env.NODE_ENV !== 'production'
const enableMockApi = process.env.USE_MOCK_DATA === 'true'
@Module({
imports: [
ConfigModule.forRoot({
Expand Down
1 change: 1 addition & 0 deletions backend/src/api/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe('main', () => {
beforeAll(async () => {
process.env.ICM_API_URL = 'http://test-icm'
process.env.ICM_TRUSTED_USERNAME = 'test-user'
process.env.ICM_API_USERNAME = 'test-user'
process.env.ICM_TOKEN_URL = 'http://test-keycloak/token'
process.env.ICM_CLIENT_ID = 'test-client'
process.env.ICM_CLIENT_SECRET = 'test-secret'
Expand Down
3 changes: 2 additions & 1 deletion backend/src/api/batches/batches.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'
import { PrismaModule } from 'src/common/database/prisma.module'
import { StateMachineModule } from 'src/common/state-machine/state-machine.module'
import { AdminModule } from '../admin/admin.module'
import { ContactsModule } from '../contacts/contacts.module'
import { BatchesController } from './batches.controller'
import { BatchesService } from './batches.service'

@Module({
imports: [PrismaModule, StateMachineModule, AdminModule],
imports: [PrismaModule, StateMachineModule, AdminModule, ContactsModule],
controllers: [BatchesController],
providers: [BatchesService],
exports: [BatchesService],
Expand Down
67 changes: 57 additions & 10 deletions backend/src/api/batches/batches.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common'
import { PrismaService } from 'src/common/database/prisma.service'
import { BATCH_DETAIL_STATUS, BATCH_EVENT, BATCH_STATUS } from 'src/common/state-machine/constants'
import {
BATCH_DETAIL_STATUS,
BATCH_EVENT,
BATCH_STATUS,
CSA_EVENT,
CSA_STATUS,
} from 'src/common/state-machine/constants'
import type { TransitionResult } from 'src/common/state-machine/interfaces'
import { StateMachineService } from 'src/common/state-machine/state-machine.service'
import { enrichLabels } from 'src/common/utils'
import { BULK_OPERATION_SKIP_REASONS, TRANSACTION_TYPES } from '../contacts/constants'
import { ContactsService } from '../contacts/contacts.service'
import { BulkOperationResponse } from '../contacts/interfaces'

export interface AddContactsResult extends BulkOperationResponse {
Expand All @@ -28,6 +36,7 @@ export class BatchesService {
constructor(
private prisma: PrismaService,
private stateMachine: StateMachineService,
private contactsService: ContactsService,
) {}

async findAll() {
Expand Down Expand Up @@ -126,7 +135,7 @@ export class BatchesService {
throw new NotFoundException(`Batch ${batchId} not found`)
}

return this.prisma.contactBatchDetail.findMany({
const details = await this.prisma.contactBatchDetail.findMany({
where: { batchId },
include: {
contact: {
Expand All @@ -141,6 +150,11 @@ export class BatchesService {
},
orderBy: { createdAt: 'desc' },
})

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

async findOrCreatePendingBatch() {
Expand Down Expand Up @@ -176,10 +190,9 @@ export class BatchesService {

const existingContacts = await this.prisma.contact.findMany({
where: { id: { in: contactIds } },
select: { id: true, caseNumber: true },
select: { id: true, caseNumber: true, csaStatus: true },
})
const existingContactIds = new Set(existingContacts.map((c) => c.id))
const contactCaseMap = new Map(existingContacts.map((c) => [c.id, c.caseNumber]))
const existingContactMap = new Map(existingContacts.map((c) => [c.id, c]))

const alreadyInBatch = await this.prisma.contactBatchDetail.findMany({
where: {
Expand All @@ -192,18 +205,45 @@ export class BatchesService {

const now = new Date()
for (const contactId of contactIds) {
if (!existingContactIds.has(contactId)) {
const contact = existingContactMap.get(contactId)
if (!contact) {
result.skipped.push({ id: contactId, reason: BULK_OPERATION_SKIP_REASONS.NOT_FOUND })
} else if (alreadyInBatchIds.has(contactId)) {
continue
}
if (alreadyInBatchIds.has(contactId)) {
result.skipped.push({ id: contactId, reason: BULK_OPERATION_SKIP_REASONS.ALREADY_IN_BATCH })
} else {
const caseNumber = contactCaseMap.get(contactId) ?? ''
continue
}

try {
const transition = await this.contactsService.updateCsaStatus(
contactId,
CSA_EVENT.ADD_TO_BATCH,
'USER',
{ userId },
)

if (!transition.success) {
result.skipped.push({
id: contactId,
reason: BULK_OPERATION_SKIP_REASONS.INVALID_TRANSITION,
})
continue
}

// Derive transaction type from the target state
const transactionType =
transition.to === CSA_STATUS.IN_BATCH_CANCELLATION
? TRANSACTION_TYPES.CANCELLATION
: TRANSACTION_TYPES.APPLICATION

const caseNumber = contact.caseNumber ?? ''
await this.prisma.$transaction(async (tx) => {
const batchDetail = await tx.contactBatchDetail.create({
data: {
contactId,
batchId: pendingBatch.id,
transactionType: TRANSACTION_TYPES.APPLICATION,
transactionType,
status: BATCH_STATUS.PENDING,
createdAt: now,
createdBy: userId,
Expand All @@ -217,6 +257,11 @@ export class BatchesService {
})
})
result.success.push(contactId)
} catch (error) {
this.logger.error(
`Failed to add contact ${contactId} to batch: ${(error as Error).message}`,
)
result.skipped.push({ id: contactId, reason: 'error' })
}
}

Expand Down Expand Up @@ -316,5 +361,7 @@ export class BatchesService {
},
}),
])

await this.contactsService.updateCsaStatus(contactId, CSA_EVENT.REMOVE_FROM_BATCH, 'USER')
}
}
Loading