Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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 },
});
Comment on lines +280 to +282

@cubic-dev-ai cubic-dev-ai Bot Jul 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A caller with member:update permission can mark a member from another organization—or a nonexistent member—as an exception because this path has no member tenant check; this can create cross-tenant completion rows or return a 500 instead of a not-found response. Validating Member with both id and organizationId before the completion lookup/create would keep this endpoint tenant-scoped.

(Based on your team's feedback about tenant scoping in the service layer.) .

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/offboarding-checklist/offboarding-checklist.service.ts, line 280:

<comment>A caller with `member:update` permission can mark a member from another organization—or a nonexistent member—as an exception because this path has no member tenant check; this can create cross-tenant completion rows or return a 500 instead of a not-found response. Validating `Member` with both `id` and `organizationId` before the completion lookup/create would keep this endpoint tenant-scoped.

(Based on your team's feedback about tenant scoping in the service layer.) .</comment>

<file context>
@@ -262,6 +264,58 @@ export class OffboardingChecklistService {
+    completedById: string;
+    reason: string;
+  }) {
+    const existing = await db.offboardingChecklistCompletion.findFirst({
+      where: { organizationId, memberId, templateItemId },
+    });
</file context>
Suggested change
const existing = await db.offboardingChecklistCompletion.findFirst({
where: { organizationId, memberId, templateItemId },
});
const member = await db.member.findFirst({
where: { id: memberId, organizationId },
select: { id: true },
});
if (!member) {
throw new NotFoundException('Member not found');
}
const existing = await db.offboardingChecklistCompletion.findFirst({
where: { organizationId, memberId, templateItemId },
});
Fix with cubic


if (existing) {
throw new BadRequestException('Item is already resolved');
}

const template = await db.offboardingChecklistTemplate.findFirst({

@cubic-dev-ai cubic-dev-ai Bot Jul 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new exception path duplicates the existing completion/template lookup logic in completeItem, which creates a second place for resolution eligibility and tenant checks to drift. A shared private lookup/guard helper would keep both resolution paths aligned.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/offboarding-checklist/offboarding-checklist.service.ts, line 288:

<comment>The new exception path duplicates the existing completion/template lookup logic in `completeItem`, which creates a second place for resolution eligibility and tenant checks to drift. A shared private lookup/guard helper would keep both resolution paths aligned.</comment>

<file context>
@@ -262,6 +264,58 @@ export class OffboardingChecklistService {
+      throw new BadRequestException('Item is already resolved');
+    }
+
+    const template = await db.offboardingChecklistTemplate.findFirst({
+      where: { id: templateItemId, organizationId, isEnabled: true },
+    });
</file context>
Fix with cubic

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(),
},
});
Comment on lines +307 to +316

@cubic-dev-ai cubic-dev-ai Bot Jul 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Concurrent requests can both observe no completion, then the unique constraint makes the losing request return an uncaught Prisma P2002 (HTTP 500) rather than the endpoint’s already-resolved 400. Catching the unique-conflict error around this create, or otherwise making resolution atomic, would preserve the API contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/offboarding-checklist/offboarding-checklist.service.ts, line 307:

<comment>Concurrent requests can both observe no completion, then the unique constraint makes the losing request return an uncaught Prisma `P2002` (HTTP 500) rather than the endpoint’s already-resolved 400. Catching the unique-conflict error around this create, or otherwise making resolution atomic, would preserve the API contract.</comment>

<file context>
@@ -262,6 +264,58 @@ export class OffboardingChecklistService {
+    // 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,
</file context>
Suggested change
return db.offboardingChecklistCompletion.create({
data: {
organizationId,
memberId,
templateItemId,
completedById,
isException: true,
exceptionReason: reason.trim(),
},
});
try {
return await db.offboardingChecklistCompletion.create({
data: {
organizationId,
memberId,
templateItemId,
completedById,
isException: true,
exceptionReason: reason.trim(),
},
});
} catch (err) {
const isPrismaConflict =
err instanceof Error &&
'code' in err &&
(err as { code: string }).code === 'P2002';
if (isPrismaConflict) {
throw new BadRequestException('Item is already resolved');
}
throw err;
}
Fix with cubic

}

async uncompleteItem({
organizationId,
memberId,
Expand Down
Loading
Loading