-
Notifications
You must be signed in to change notification settings - Fork 349
feat(offboarding): mark checklist steps as exceptions with a reason #3490
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async uncompleteItem({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| memberId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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:updatepermission 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. ValidatingMemberwith bothidandorganizationIdbefore 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