diff --git a/apps/api/src/offboarding-checklist/dto/mark-checklist-exception.dto.spec.ts b/apps/api/src/offboarding-checklist/dto/mark-checklist-exception.dto.spec.ts new file mode 100644 index 0000000000..30694a3f59 --- /dev/null +++ b/apps/api/src/offboarding-checklist/dto/mark-checklist-exception.dto.spec.ts @@ -0,0 +1,22 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { MarkChecklistExceptionDto } from './mark-checklist-exception.dto'; + +describe('MarkChecklistExceptionDto', () => { + it('accepts a non-empty reason', async () => { + const dto = plainToInstance(MarkChecklistExceptionDto, { + reason: 'No company device was ever issued', + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('rejects a missing reason', async () => { + const dto = plainToInstance(MarkChecklistExceptionDto, {}); + expect((await validate(dto)).length).toBeGreaterThan(0); + }); + + it('rejects a whitespace-only reason', async () => { + const dto = plainToInstance(MarkChecklistExceptionDto, { reason: ' ' }); + expect((await validate(dto)).length).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/offboarding-checklist/dto/mark-checklist-exception.dto.ts b/apps/api/src/offboarding-checklist/dto/mark-checklist-exception.dto.ts new file mode 100644 index 0000000000..93ca8bce00 --- /dev/null +++ b/apps/api/src/offboarding-checklist/dto/mark-checklist-exception.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Matches } from 'class-validator'; + +export class MarkChecklistExceptionDto { + @ApiProperty({ + description: + 'Why this offboarding step is being marked as an exception (could not or need not be done). Required and non-empty.', + example: 'This person was never issued a company device.', + }) + @IsString() + @IsNotEmpty() + // Reject whitespace-only reasons — an exception must be justified. + @Matches(/\S/, { message: 'reason must not be empty' }) + reason!: string; +} diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.controller.spec.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.spec.ts new file mode 100644 index 0000000000..671373055d --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.spec.ts @@ -0,0 +1,79 @@ +import { BadRequestException } from '@nestjs/common'; + +// The controller imports `db` from `@db` at module load; stub it so the test +// doesn't spin up a real Prisma client (markException delegates to the service). +jest.mock('@db', () => ({ db: {} })); + +// The guard imports pull in @trycompai/auth → better-auth (ESM), which jest +// can't transform. We call controller methods directly (guards never run), so +// stub the guard modules to cut that import chain. +jest.mock('../auth/hybrid-auth.guard', () => ({ HybridAuthGuard: class {} })); +jest.mock('../auth/permission.guard', () => ({ PermissionGuard: class {} })); + +import { OffboardingChecklistController } from './offboarding-checklist.controller'; +import type { AuthContext as AuthContextType } from '../auth/types'; + +describe('OffboardingChecklistController', () => { + const service = { markException: jest.fn() }; + const exportService = {}; + let controller: OffboardingChecklistController; + + beforeEach(() => { + jest.clearAllMocks(); + controller = new OffboardingChecklistController( + service as never, + exportService as never, + ); + }); + + const sessionContext: AuthContextType = { + authType: 'session', + userId: 'usr_1', + userEmail: 'user@example.com', + organizationId: 'org_1', + memberId: 'mem_1', + isApiKey: false, + isPlatformAdmin: false, + userRoles: null, + }; + + describe('markException', () => { + it('delegates to the service with the acting user and reason', async () => { + service.markException.mockResolvedValue({ id: 'occ_1' }); + + const result = await controller.markException( + 'org_1', + sessionContext, + 'mem_1', + 'oct_1', + { reason: 'No company device was ever issued' }, + ); + + expect(service.markException).toHaveBeenCalledWith({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + reason: 'No company device was ever issued', + }); + expect(result).toEqual({ id: 'occ_1' }); + }); + + it('rejects when there is no user context', async () => { + const apiKeyContext: AuthContextType = { + authType: 'api-key', + organizationId: 'org_1', + isApiKey: true, + isPlatformAdmin: false, + userRoles: null, + }; + + await expect( + controller.markException('org_1', apiKeyContext, 'mem_1', 'oct_1', { + reason: 'No device', + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(service.markException).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts index 054e251fe9..c560f9a242 100644 --- a/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts @@ -24,6 +24,7 @@ import { OffboardingExportService } from './offboarding-export.service'; import { CreateTemplateItemDto } from './dto/create-template-item.dto'; import { UpdateTemplateItemDto } from './dto/update-template-item.dto'; import { CompleteChecklistItemDto } from './dto/complete-checklist-item.dto'; +import { MarkChecklistExceptionDto } from './dto/mark-checklist-exception.dto'; @ApiTags('Offboarding Checklist') @Controller({ path: 'offboarding-checklist', version: '1' }) @@ -243,6 +244,29 @@ export class OffboardingChecklistController { }); } + @Post('member/:memberId/item/:templateItemId/exception') + @RequirePermission('member', 'update') + @ApiOperation({ + summary: 'Mark an offboarding checklist item as an exception', + description: + "Resolves an offboarding checklist item as an exception — the step could not or need not be done for this member — with a required reason, instead of completing it with evidence. The reason is recorded in the audit log and the offboarding evidence export.", + }) + async markException( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('memberId') memberId: string, + @Param('templateItemId') templateItemId: string, + @Body() dto: MarkChecklistExceptionDto, + ) { + return this.offboardingChecklistService.markException({ + organizationId, + memberId, + templateItemId, + completedById: this.requireUserId(authContext), + reason: dto.reason, + }); + } + @Post('member/:memberId/item/:templateItemId/evidence') @RequirePermission('member', 'update') @ApiOperation({ diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts index 48b6636152..9f7432cdfe 100644 --- a/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts @@ -167,10 +167,46 @@ describe('OffboardingChecklistService', () => { expect(result.totalItems).toBe(2); expect(result.completedItems).toBe(1); expect(result.items[0].completed).toBe(true); + expect(result.items[0].isException).toBe(false); expect(result.items[0].evidence).toHaveLength(1); expect(result.items[1].completed).toBe(false); expect(result.items[1].evidence).toHaveLength(0); }); + + it('surfaces exception state and reason on the item', async () => { + mockDb.offboardingChecklistTemplate.count.mockResolvedValue(1); + mockDb.offboardingChecklistTemplate.findMany.mockResolvedValue([ + { + id: 'oct_1', + organizationId: 'org_1', + title: 'Retrieve company devices', + isEnabled: true, + sortOrder: 1, + }, + ]); + mockDb.offboardingChecklistCompletion.findMany.mockResolvedValue([ + { + id: 'occ_1', + templateItemId: 'oct_1', + memberId: 'mem_1', + completedById: 'usr_1', + completedBy: { id: 'usr_1', name: 'Test User' }, + isException: true, + exceptionReason: 'No company device was ever issued', + }, + ]); + mockDb.attachment.findMany.mockResolvedValue([]); + + const result = await service.getMemberChecklist('org_1', 'mem_1'); + + // An exception counts as resolved (completed) but is flagged distinctly. + expect(result.items[0].completed).toBe(true); + expect(result.completedItems).toBe(1); + expect(result.items[0].isException).toBe(true); + expect(result.items[0].exceptionReason).toBe( + 'No company device was ever issued', + ); + }); }); describe('completeItem', () => { @@ -287,6 +323,126 @@ describe('OffboardingChecklistService', () => { }); }); + describe('markException', () => { + it('creates an exception completion with the reason and no evidence', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + // evidenceRequired is true to prove the exception path bypasses it. + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue({ + id: 'oct_1', + organizationId: 'org_1', + isEnabled: true, + isAccessRevocation: false, + evidenceRequired: true, + }); + mockDb.offboardingChecklistCompletion.create.mockResolvedValue({ + id: 'occ_1', + isException: true, + exceptionReason: 'No company device was ever issued', + }); + + const result = await service.markException({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + reason: 'No company device was ever issued', + }); + + expect(result.id).toBe('occ_1'); + expect( + mockDb.offboardingChecklistCompletion.create, + ).toHaveBeenCalledWith({ + data: { + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + isException: true, + exceptionReason: 'No company device was ever issued', + }, + }); + expect(mockAttachmentsService.uploadAttachment).not.toHaveBeenCalled(); + }); + + it('trims the reason before saving', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue({ + id: 'oct_1', + organizationId: 'org_1', + isEnabled: true, + isAccessRevocation: false, + }); + mockDb.offboardingChecklistCompletion.create.mockResolvedValue({ id: 'occ_1' }); + + await service.markException({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + reason: ' No device issued ', + }); + + expect( + mockDb.offboardingChecklistCompletion.create, + ).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ exceptionReason: 'No device issued' }), + }), + ); + }); + + it('throws if the step is already resolved', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue({ + id: 'occ_1', + }); + + await expect( + service.markException({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + reason: 'No device issued', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('throws if the template item is not found', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue(null); + + await expect( + service.markException({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_invalid', + completedById: 'usr_1', + reason: 'No device issued', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws when the step is the access-revocation step', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue({ + id: 'oct_ar', + organizationId: 'org_1', + isEnabled: true, + isAccessRevocation: true, + }); + + await expect( + service.markException({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_ar', + completedById: 'usr_1', + reason: 'Handled elsewhere', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + describe('uncompleteItem', () => { it('deletes completion and associated evidence', async () => { mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue({ diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts index 23fadea7cc..808e73d5f3 100644 --- a/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts @@ -172,6 +172,8 @@ export class OffboardingChecklistService { ...template, templateItemId: template.id, completed: !!completion, + isException: completion?.isException ?? false, + exceptionReason: completion?.exceptionReason ?? null, completion: completion ?? null, evidence, }; @@ -262,6 +264,58 @@ export class OffboardingChecklistService { return completion; } + async markException({ + organizationId, + memberId, + templateItemId, + completedById, + reason, + }: { + organizationId: string; + memberId: string; + templateItemId: string; + completedById: string; + reason: string; + }) { + const existing = await db.offboardingChecklistCompletion.findFirst({ + where: { organizationId, memberId, templateItemId }, + }); + + if (existing) { + throw new BadRequestException('Item is already resolved'); + } + + const template = await db.offboardingChecklistTemplate.findFirst({ + where: { id: templateItemId, organizationId, isEnabled: true }, + }); + + if (!template) { + throw new NotFoundException('Template item not found'); + } + + // The access-revocation step is driven by the per-vendor revocation flow, + // so it can't be blanket-excepted here. + if (template.isAccessRevocation) { + throw new BadRequestException( + 'The access revocation step cannot be marked as an exception', + ); + } + + // An exception resolves the step without evidence (that's the point — the + // step could not or need not be done), so the evidenceRequired check that + // completeItem enforces is intentionally skipped. + return db.offboardingChecklistCompletion.create({ + data: { + organizationId, + memberId, + templateItemId, + completedById, + isException: true, + exceptionReason: reason.trim(), + }, + }); + } + async uncompleteItem({ organizationId, memberId, diff --git a/apps/api/src/offboarding-checklist/offboarding-export-summary.spec.ts b/apps/api/src/offboarding-checklist/offboarding-export-summary.spec.ts new file mode 100644 index 0000000000..ce545d6c5a --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-export-summary.spec.ts @@ -0,0 +1,57 @@ +// The export service imports `db` from `@db` at module load; stub it so this +// pure-function test doesn't spin up a real Prisma client. +jest.mock('@db', () => ({ + db: {}, + AttachmentEntityType: { offboarding_checklist: 'offboarding_checklist' }, +})); + +import { buildSummaryCsv } from './offboarding-export.service'; + +type Items = Parameters[0]; + +describe('buildSummaryCsv', () => { + it('flags excepted items with an Exception status and includes the reason', () => { + const csv = buildSummaryCsv([ + { + title: 'Retrieve company devices', + completed: true, + isException: true, + exceptionReason: 'No company device was ever issued', + completion: null, + evidence: [], + }, + ] as unknown as Items); + + const [header, row] = csv.split('\n'); + expect(header).toContain('Exception Reason'); + expect(row).toContain('Exception'); + expect(row).toContain('No company device was ever issued'); + }); + + it('uses Complete / Pending and a blank reason for non-exception items', () => { + const csv = buildSummaryCsv([ + { + title: 'Done thing', + completed: true, + isException: false, + exceptionReason: null, + completion: null, + evidence: [], + }, + { + title: 'Pending thing', + completed: false, + isException: false, + exceptionReason: null, + completion: null, + evidence: [], + }, + ] as unknown as Items); + + const lines = csv.split('\n'); + expect(lines[1]).toContain('"Done thing",Complete'); + expect(lines[2]).toContain('"Pending thing",Pending'); + // A completed/pending row ends with an empty reason field. + expect(lines[1].endsWith('""')).toBe(true); + }); +}); diff --git a/apps/api/src/offboarding-checklist/offboarding-export.service.ts b/apps/api/src/offboarding-checklist/offboarding-export.service.ts index 034f100c88..6cbe3083c4 100644 --- a/apps/api/src/offboarding-checklist/offboarding-export.service.ts +++ b/apps/api/src/offboarding-checklist/offboarding-export.service.ts @@ -71,18 +71,7 @@ export class OffboardingExportService { items: ChecklistItems, prefix = '', ) { - const rows = [ - 'Item,Status,Completed By,Completed Date,Evidence Count', - ...items.map((item) => { - const status = item.completed ? 'Complete' : 'Pending'; - const completedBy = item.completion?.completedBy?.name ?? ''; - const completedDate = item.completion?.completedAt - ? new Date(item.completion.completedAt).toISOString().split('T')[0] - : ''; - return `"${escapeCsvField(item.title)}",${status},"${escapeCsvField(completedBy)}",${completedDate},${item.evidence.length}`; - }), - ]; - archive.append(rows.join('\n'), { name: `${prefix}summary.csv` }); + archive.append(buildSummaryCsv(items), { name: `${prefix}summary.csv` }); } private appendVendorRevocationsCsv( @@ -223,6 +212,33 @@ export class OffboardingExportService { } } +/** + * Builds the offboarding summary CSV. Excepted steps are reported with an + * `Exception` status and their justification in the `Exception Reason` column — + * that reason is the audit artifact for a step that was legitimately skipped. + */ +export function buildSummaryCsv(items: ChecklistItems): string { + const rows = [ + 'Item,Status,Completed By,Completed Date,Evidence Count,Exception Reason', + ...items.map((item) => { + const status = summaryStatus(item); + const completedBy = item.completion?.completedBy?.name ?? ''; + const completedDate = item.completion?.completedAt + ? new Date(item.completion.completedAt).toISOString().split('T')[0] + : ''; + const reason = item.exceptionReason ?? ''; + return `"${escapeCsvField(item.title)}",${status},"${escapeCsvField(completedBy)}",${completedDate},${item.evidence.length},"${escapeCsvField(reason)}"`; + }), + ]; + return rows.join('\n'); +} + +function summaryStatus(item: ChecklistItems[number]): string { + if (item.isException) return 'Exception'; + if (item.completed) return 'Complete'; + return 'Pending'; +} + function sanitizeFileName(name: string): string { return name.replace(/.*[/\\]/, '').replace(/[/\\]/g, '_') || 'file'; } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/ExceptionReasonForm.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/ExceptionReasonForm.test.tsx new file mode 100644 index 0000000000..3424c85205 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/ExceptionReasonForm.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@trycompai/design-system', () => ({ + Textarea: (props: Record) =>