Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dbff79e
add upadate & delete api for data quality steward user
mdsaraza-png Aug 3, 2026
514cb39
Merge branch 'dev' of https://github.com/bcgov/csa into feat/contact-…
mdsaraza-png Aug 4, 2026
aa87d53
update admin service logic
mdsaraza-png Aug 4, 2026
f7dd3f4
resolve merge conflict
mdsaraza-png Aug 6, 2026
5dcc814
fix build issue
mdsaraza-png Aug 6, 2026
e864565
Merge branch 'dev' into feat/contact-update-delete-api
mdiapenabc Aug 7, 2026
6e96f3c
update delete api
mdsaraza-png Aug 7, 2026
7c63472
take pull from origin
mdsaraza-png Aug 7, 2026
c48f896
fix: address DQ contact update/delete API review feedback
mdiapenabc Aug 7, 2026
ad69490
Merge branch 'dev' into feat/contact-update-delete-api
mdiapenabc Aug 7, 2026
8d1de90
fix: explicit audit trail delete and RESTRICT FK for BL-37
mdiapenabc Aug 7, 2026
e6b4d32
fix: ensure all contact-scoped dependencies are deleted on DQ hard-de…
mdiapenabc Aug 7, 2026
fb71542
fix pritter issue
mdsaraza-png Aug 7, 2026
b69e3a6
fix: restore AdminModule import for CSAGuard in ContactsModule
mdiapenabc Aug 7, 2026
067d644
chore: drop unrelated changes reverted by dev merges
mdiapenabc Aug 7, 2026
b5dfbd6
fix: restore needs_review lifecycle from dev (#435)
mdiapenabc Aug 7, 2026
0408bf9
fix: remove duplicate AdminModule import in ContactsModule
mdiapenabc Aug 7, 2026
818352f
chore: remove unused admin constants index stub
mdiapenabc Aug 7, 2026
fbc86b2
fix: validate CSA status against state machine enum on DQ update
mdiapenabc Aug 7, 2026
df8352a
Merge branch 'dev' into feat/contact-update-delete-api
mdiapenabc Aug 7, 2026
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
2 changes: 1 addition & 1 deletion backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ model ContactAuditTrail {
oldValue String? @map("old_value")
newValue String? @map("new_value")

contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id])

@@index([contactId, actionedAt(sort: Desc)])
@@map("contact_audit_trail")
Expand Down
33 changes: 33 additions & 0 deletions backend/src/api/admin/constants/user-profile.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* User profile types for CSA application access control.
* These profiles determine what actions a user can perform.
*/
export const USER_PROFILE = {
/** Standard CSA user - read-only access */
CSA_STANDARD: 'CSA_STANDARD',

/** Data Quality Steward - can update and delete contact records */
DATA_QUALITY_STEWARD: 'DATA_QUALITY_STEWARD',
} as const

export type UserProfile = (typeof USER_PROFILE)[keyof typeof USER_PROFILE]

/**
* ICM responsibility that grants Data Quality Steward privileges
*/
export const DATA_STEWARD_ICM_RESPONSIBILITY = 'ICM DATA STEWARD'

/**
* ICM responsibilities that grant CSA access
*/
export const CSA_ACCESS_ICM_RESPONSIBILITIES = [
'ICM CSA APPLICATION - RW',
'ICM CSA APPLICATION - RO',
]

/**
* Validates if a user profile string is valid
*/
export function isValidUserProfile(profile: string): profile is UserProfile {
return Object.values(USER_PROFILE).includes(profile as UserProfile)
}
1 change: 1 addition & 0 deletions backend/src/api/common/decorators/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './current-user.decorator'
export * from './decoded-token.decorator'
export * from './skip-csa-check.decorator'
export * from './user-profile.decorator'
14 changes: 14 additions & 0 deletions backend/src/api/common/decorators/user-profile.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
import { Request } from 'express'
import { UserProfile } from 'src/api/admin/constants/user-profile.constants'

/**
* Extracts the user profile set by CSAGuard after ICM verification.
* Returns the UserProfile type (DATA_QUALITY_STEWARD or CSA_STANDARD) or null.
*/
export const UserProfileDecorator = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): UserProfile | null => {
const request = ctx.switchToHttp().getRequest<Request>()
return (request as any).userProfile ?? null
},
)
20 changes: 17 additions & 3 deletions backend/src/api/common/guards/csa.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Request } from 'express'
import { JwtVerificationService } from 'src/common/auth/jwt-verification.service'
import { extractUsernameFromPayload } from 'src/common/auth/token-utils'
import { AdminService } from '../../admin/admin.service'
import { isValidUserProfile, UserProfile } from '../../admin/constants/user-profile.constants'

interface JwtPayload {
exp?: number
Expand All @@ -22,8 +23,11 @@ interface JwtPayload {
}

// In-memory cache for CSA access results
// Key: username, Value: { hasAccess: boolean, expiresAt: number }
const csaAccessCache = new Map<string, { hasAccess: boolean; expiresAt: number }>()
// Key: username, Value: { hasAccess: boolean, userProfile: UserProfile | null, expiresAt: number }
const csaAccessCache = new Map<
string,
{ hasAccess: boolean; userProfile?: UserProfile; expiresAt: number }
>()
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes cache TTL

export const SKIP_CSA_CHECK_KEY = 'skipCSACheck'
Expand Down Expand Up @@ -83,16 +87,23 @@ export class CSAGuard implements CanActivate {
if (!cached.hasAccess) {
throw new UnauthorizedException('User does not have CSA access')
}
// Attach cached user profile to request
;(request as any).userProfile = cached.userProfile ?? null
return true
}

// Verify CSA access via admin service
// Verify CSA access and fetch user profile from ICM
this.logger.debug(`Verifying CSA access for user: ${username}`)
const csaAccessResult = await this.adminService.verifyCSAAccess(username)
const userProfile =
csaAccessResult.userProfile && isValidUserProfile(csaAccessResult.userProfile)
? csaAccessResult.userProfile
: null

// Cache the result
csaAccessCache.set(username, {
hasAccess: csaAccessResult.hasAccess,
...(userProfile && { userProfile }),
expiresAt: Date.now() + CACHE_TTL_MS,
})

Expand All @@ -101,6 +112,9 @@ export class CSAGuard implements CanActivate {
throw new UnauthorizedException(csaAccessResult.message || 'User does not have CSA access')
}

// Attach user profile to request for use in route handlers
;(request as any).userProfile = userProfile

return true
}

Expand Down
73 changes: 54 additions & 19 deletions backend/src/api/contacts/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { CSA_STATUS } from 'src/common/state-machine/constants'

export const ALLOWED_FILTER_SORT_FIELDS = [
'id',
'lastName',
Expand Down Expand Up @@ -33,25 +35,6 @@ export const ALLOWED_FILTER_SORT_FIELDS = [
'birthPlace',
] as const

export const CSA_STATUSES = {
ELIGIBLE: 'eligible',
ELIGIBLE_TBD: 'eligible_tbd',
NOT_ELIGIBLE_OUT_OF_PAY: 'not_eligible_out_of_pay',
ON_HOLD: 'on_hold',
IN_BATCH_APPLICATION: 'in_batch_application',
BATCH_SENT_APPLICATION: 'batch_sent_application',
APPLICATION_REFUSED_CRA: 'application_refused_cra',
IN_PAY: 'in_pay',
NOT_ELIGIBLE_IN_PAY: 'not_eligible_in_pay',
NOT_ELIGIBLE_IP_TBD: 'not_eligible_ip_tbd',
IN_BATCH_CANCELLATION: 'in_batch_cancellation',
BATCH_SENT_CANCELLATION: 'batch_sent_cancellation',
CANCELLATION_REFUSED_CRA: 'cancellation_refused_cra',
OVER_18: 'over_18',
} as const

export type CsaStatus = (typeof CSA_STATUSES)[keyof typeof CSA_STATUSES]

export const BATCH_STATUSES = {
PENDING: 'pending',
IN_PROGRESS: 'in_progress',
Expand Down Expand Up @@ -83,3 +66,55 @@ export const BULK_OPERATION_SKIP_REASONS = {
ALREADY_IN_BATCH: 'already_in_batch',
INVALID_TRANSITION: 'invalid_transition',
} as const

/**
* Protected CSA statuses that prevent edit/delete operations (BL-35)
* Note: OVER_18 is NOT protected - DQ can still edit/delete those records
*/
export const PROTECTED_CSA_STATUSES = new Set<string>([
CSA_STATUS.ON_HOLD,
CSA_STATUS.IN_BATCH_APPLICATION,
CSA_STATUS.IN_BATCH_CANCELLATION,
CSA_STATUS.BATCH_SENT_APPLICATION,
CSA_STATUS.BATCH_SENT_CANCELLATION,
CSA_STATUS.APPLICATION_REFUSED_CRA,
CSA_STATUS.CANCELLATION_REFUSED_CRA,
CSA_STATUS.CRA_ERROR_APPLICATION,
CSA_STATUS.CRA_ERROR_CANCELLATION,
])

/**
* Fields that are auditable (tracked in contact_audit_trail)
*/
export const AUDITABLE_FIELDS = {
DIN: 'din',
CSA_STATUS: 'csaStatus',
CSA_STATUS_EFFECTIVE_DATE: 'csaStatusEffectiveDate',
CSA_SENT_DATE: 'csaSentDate',
} as const

/**
* Application tables with FK to contacts.id — must be cleared before contact delete (BL-37).
* Shared entities (batches, transfer_files) are intentionally excluded.
*/
export const CONTACT_DELETE_APPLICATION_TABLES = [
'wkl_file_records',
'contact_batch_details',
'contact_audit_trail',
] as const

/**
* Staging tables keyed by person_id_icm / person_id_mis — cleared on DQ hard-delete (BL-37).
* stg_icm_legal_authority_admin is a shared lookup table and is not contact-scoped.
*/
export const CONTACT_DELETE_STAGING_TABLES = [
'stg_icm_orders',
'stg_icm_agreement',
'stg_icm_placements',
'stg_icm_legal_authority',
'stg_icm_agreement_line',
'stg_icm_cases',
'stg_mis_contracts',
'stg_mis_payments',
'stg_mis_placements',
] as const
35 changes: 33 additions & 2 deletions backend/src/api/contacts/contacts.controller.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpException,
Param,
ParseIntPipe,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common'
import { ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'
import { PaginatedResponse } from 'src/api/common/dto/paginated-response.dto'
import { AuditTrailService } from '../audit-trail/audit-trail.service'
import { CurrentUser } from '../common/decorators'
import { CurrentUser, UserProfileDecorator } from '../common/decorators'
import {
ContactIdsWithActionDto,
HoldContactsDto,
Expand All @@ -23,7 +25,7 @@ import {
} from '../common/dto/contact-ids.dto'
import { CSAGuard } from '../common/guards/csa.guard'
import { ContactsService } from './contacts.service'
import { ContactDto } from './dto/contact.dto'
import { ContactDto, UpdateContactDto } from './dto/contact.dto'
import { BulkOperationResponse } from './interfaces'

@ApiTags('contacts')
Expand Down Expand Up @@ -238,4 +240,33 @@ export class ContactsController {
async clearReviewFlag(@Param('id', ParseIntPipe) id: number, @CurrentUser() userId: string) {
return this.contactsService.clearReviewFlag(id, userId)
}

@Put(':id/update')
@HttpCode(200)
@ApiResponse({ status: 200, description: 'Contact updated successfully' })
@ApiResponse({ status: 404, description: 'Contact not found' })
@ApiResponse({ status: 403, description: 'Forbidden - Data Quality Steward role required' })
@ApiResponse({ status: 422, description: 'Contact in protected status' })
async updateContact(
@Param('id', ParseIntPipe) id: number,
@Body() updateContactDto: UpdateContactDto,
@CurrentUser() userId: string,
@UserProfileDecorator() userProfile: string | null,
) {
return this.contactsService.updateContact(id, updateContactDto, userId, userProfile)
}

@Delete(':id')
@HttpCode(200)
@ApiResponse({ status: 200, description: 'Contact permanently deleted successfully' })
@ApiResponse({ status: 404, description: 'Contact not found' })
@ApiResponse({ status: 403, description: 'Forbidden - Data Quality Steward role required' })
@ApiResponse({ status: 422, description: 'Contact in protected status' })
async deleteContact(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() userId: string,
@UserProfileDecorator() userProfile: string | null,
) {
return this.contactsService.deleteContact(id, userId, userProfile)
}
}
Loading
Loading