Skip to content

feat(offboarding): mark checklist steps as exceptions with a reason#3490

Open
Marfuen wants to merge 1 commit into
mainfrom
mariano/offboarding-step-exceptions
Open

feat(offboarding): mark checklist steps as exceptions with a reason#3490
Marfuen wants to merge 1 commit into
mainfrom
mariano/offboarding-step-exceptions

Conversation

@Marfuen

@Marfuen Marfuen commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

What & why

Some offboarding checklist steps can't be fulfilled for a given person — e.g. Retrieve company devices when they were never issued one. Today a step has one resolution (complete, sometimes with evidence), so an unfulfillable step stays permanently incomplete, shows as "remaining", and the offboarding never looks done. There's also no record of why it was skipped.

This adds an exception resolution: mark a step as an exception with a required free-text reason. It resolves the step (clears "remaining", counts toward progress, stops blocking completion) but is visually and semantically distinct from a real completion, and the reason is preserved for the audit log and evidence export.

Design note

An offboarding step has no per-member row until it's resolved — the OffboardingChecklistCompletion row is the resolution (@@unique(memberId, templateItemId)). "Completed" and "exception" are mutually-exclusive ways to resolve the same step, so this reuses that row with an isException discriminator rather than a separate table (unlike FindingException, which suppresses an independently-existing finding). No expiry/revoke lifecycle — "remove exception" is just deleting the row (existing uncomplete flow).

Backend

  • OffboardingChecklistCompletion gains isException + exceptionReason (additive migration, no backfill).
  • POST /v1/offboarding-checklist/member/:memberId/item/:templateItemId/exception (member:update, auto-audited — the interceptor folds reason into the record). markException skips the evidence requirement and rejects the access-revocation step. getMemberChecklist surfaces the fields; an exception counts as resolved.
  • Evidence-export summary CSV now reports an Exception status and the reason in a new Exception Reason column — the audit artifact for a legitimately skipped step.

Frontend

  • "Mark as exception" action with an inline required-reason form (Save disabled until non-empty), an Exception badge, a distinct amber status indicator, the reason shown in the expanded row, and a Remove exception action.
  • Split the oversized OffboardingChecklistItem.tsx (413 → 181 lines) into focused indicator/content modules to stay under the 300-line limit.

Testing (TDD)

  • API: markException (creates the exception row, bypasses evidence, rejects if already resolved / on the access-revocation step), controller delegation + no-user guard, DTO validation, and buildSummaryCsv (Exception status + reason column).
  • App: ExceptionReasonForm (required-reason gating, trimmed submit) and OffboardingChecklistItem (mark → form → hook call; excepted view shows reason + Remove).
  • Typecheck clean on all changed files; no @trycompai/ui / lucide-react imports.

⚠️ Migration

Adds a Prisma migration (add_offboarding_checklist_exception) — run prisma migrate on deploy.

🤖 Generated with Claude Code


Summary by cubic

Add an exception resolution for offboarding checklist steps with a required reason. This lets you mark unfulfillable steps as handled without evidence, counts toward progress, and is clearly shown as an exception in the UI and export.

  • New Features

    • API: POST /v1/offboarding-checklist/member/:memberId/item/:templateItemId/exception records isException and exceptionReason, skips evidence, and rejects the access‑revocation step.
    • Checklist: exceptions are treated as resolved; UI shows an Exception badge, amber status, the reason, and a “Remove exception” action.
    • Export: summary CSV adds an Exception status and an “Exception Reason” column.
    • Frontend: inline “Mark as exception” reason form (required, trimmed); refactored OffboardingChecklistItem into focused indicator/content modules.
  • Migration

    • Run prisma migrate to add isException and exceptionReason to OffboardingChecklistCompletion.

Written for commit d820421. Summary will update on new commits.

Review in cubic

Some offboarding steps can't be fulfilled for a given person (e.g. "Retrieve
company devices" when they never had one), leaving the checklist permanently
incomplete. Add an exception resolution: a required free-text reason marks a
step as handled without evidence, so it clears "remaining" and stops blocking
completion, while staying visually and semantically distinct from a real
completion.

Backend:
- OffboardingChecklistCompletion gains isException + exceptionReason (additive
  migration). The unique (memberId, templateItemId) row is the single
  resolution slot, so a step is completed XOR excepted.
- New POST .../member/:memberId/item/:templateItemId/exception (member:update,
  auto-audited). markException skips the evidence requirement and rejects the
  access-revocation step. getMemberChecklist surfaces the fields; an exception
  counts as resolved.
- Evidence export summary CSV reports an "Exception" status + the reason in a new
  "Exception Reason" column — the audit artifact for a skipped step.

Frontend:
- "Mark as exception" action with an inline required-reason form, an Exception
  badge, a distinct status indicator, the reason shown in the row, and a
  "Remove exception" action.
- Split the oversized OffboardingChecklistItem into focused indicator/content
  modules (413 -> 181 lines).

Tests (TDD): service, controller, DTO, summary CSV, and the exception form +
item behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
app Ready Ready Preview, Comment Jul 22, 2026 8:48pm
comp-framework-editor Ready Ready Preview, Comment Jul 22, 2026 8:48pm
portal Ready Ready Preview, Comment Jul 22, 2026 8:48pm

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

6 issues found across 18 files

Confidence score: 2/5

  • apps/api/src/offboarding-checklist/offboarding-checklist.service.ts is missing a member tenant/existence guard on the exception path, so a user with member:update could write exception completions for another org (or nonexistent members), creating cross-tenant data integrity/security impact if merged as-is — add the same tenant/member validation used in the normal completion flow before merging.
  • apps/api/src/offboarding-checklist/offboarding-checklist.service.ts can throw an uncaught Prisma P2002 during concurrent exception requests, returning HTTP 500 instead of the endpoint’s expected client error behavior and causing avoidable retries/noisy failures — catch and translate the unique-constraint race to the intended 400/idempotent response.
  • OffboardingChecklist.tsx and ExceptionReasonForm.tsx currently close the reason form even when mark-exception fails, and the submit promise can be left unobserved, so users may think the exception saved while losing their typed reason — await/handle onSubmit, keep the form open on failure, and show inline error feedback.
  • offboarding-item-content.tsx uses a clickable <div> for evidence upload without keyboard activation, which blocks keyboard-only users from opening the picker — add proper button/label semantics with Enter/Space support before release.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklist.tsx">

<violation number="1" location="apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklist.tsx:70">
P2: When marking an exception fails, the inline reason form is still closed, so the user can believe the exception was saved and lose the entered reason. The catch consumes the rejection while the child closes the form after a resolved callback; rethrowing after the toast would keep failed saves visible and retryable.</violation>
</file>

<file name="apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/ExceptionReasonForm.tsx">

<violation number="1" location="apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/ExceptionReasonForm.tsx:38">
P2: A failed mark-exception request produces an unhandled promise rejection because this click handler does not observe the promise returned by `onSubmit`; the parent callback propagates API errors. The form should expose/handle submission errors (or otherwise catch and surface the rejection) so users can retry without a console/runtime error.</violation>
</file>

<file name="apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/offboarding-item-content.tsx">

<violation number="1" location="apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/offboarding-item-content.tsx:188">
P2: Keyboard-only users cannot open the evidence picker because this upload dropzone is a clickable `<div>` without focusability or keyboard activation. Adding button semantics with Enter/Space handling, or using a `<label>` associated with the hidden input, keeps evidence upload accessible.</violation>
</file>

<file name="apps/api/src/offboarding-checklist/offboarding-checklist.service.ts">

<violation number="1" location="apps/api/src/offboarding-checklist/offboarding-checklist.service.ts:280">
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.) [FEEDBACK_USED].</violation>

<violation number="2" location="apps/api/src/offboarding-checklist/offboarding-checklist.service.ts:288">
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.</violation>

<violation number="3" location="apps/api/src/offboarding-checklist/offboarding-checklist.service.ts:307">
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.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment on lines +280 to +282
const existing = await db.offboardingChecklistCompletion.findFirst({
where: { organizationId, memberId, templateItemId },
});

@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

await markException({ templateItemId, reason });
toast.success('Item marked as exception');
} catch {
toast.error('Failed to mark item as exception');

@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: When marking an exception fails, the inline reason form is still closed, so the user can believe the exception was saved and lose the entered reason. The catch consumes the rejection while the child closes the form after a resolved callback; rethrowing after the toast would keep failed saves visible and retryable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklist.tsx, line 70:

<comment>When marking an exception fails, the inline reason form is still closed, so the user can believe the exception was saved and lose the entered reason. The catch consumes the rejection while the child closes the form after a resolved callback; rethrowing after the toast would keep failed saves visible and retryable.</comment>

<file context>
@@ -54,6 +55,24 @@ export function OffboardingChecklist({
+        await markException({ templateItemId, reason });
+        toast.success('Item marked as exception');
+      } catch {
+        toast.error('Failed to mark item as exception');
+      }
+    },
</file context>
Fix with cubic

<HStack gap="2">
<Button
size="sm"
onClick={() => onSubmit(trimmed)}

@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: A failed mark-exception request produces an unhandled promise rejection because this click handler does not observe the promise returned by onSubmit; the parent callback propagates API errors. The form should expose/handle submission errors (or otherwise catch and surface the rejection) so users can retry without a console/runtime error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/ExceptionReasonForm.tsx, line 38:

<comment>A failed mark-exception request produces an unhandled promise rejection because this click handler does not observe the promise returned by `onSubmit`; the parent callback propagates API errors. The form should expose/handle submission errors (or otherwise catch and surface the rejection) so users can retry without a console/runtime error.</comment>

<file context>
@@ -0,0 +1,55 @@
+      <HStack gap="2">
+        <Button
+          size="sm"
+          onClick={() => onSubmit(trimmed)}
+          disabled={!trimmed || isSubmitting}
+          loading={isSubmitting}
</file context>
Fix with cubic

<div
onDrop={onFileDrop}
onDragOver={(e) => e.preventDefault()}
onClick={() => !isProcessing && dropzoneInputRef.current?.click()}

@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: Keyboard-only users cannot open the evidence picker because this upload dropzone is a clickable <div> without focusability or keyboard activation. Adding button semantics with Enter/Space handling, or using a <label> associated with the hidden input, keeps evidence upload accessible.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/offboarding-item-content.tsx, line 188:

<comment>Keyboard-only users cannot open the evidence picker because this upload dropzone is a clickable `<div>` without focusability or keyboard activation. Adding button semantics with Enter/Space handling, or using a `<label>` associated with the hidden input, keeps evidence upload accessible.</comment>

<file context>
@@ -0,0 +1,248 @@
+        <div
+          onDrop={onFileDrop}
+          onDragOver={(e) => e.preventDefault()}
+          onClick={() => !isProcessing && dropzoneInputRef.current?.click()}
+          className={`flex cursor-pointer flex-col items-center gap-2 rounded-md border-2 border-dashed border-muted-foreground/25 px-4 py-6 text-center transition hover:border-muted-foreground/50 hover:bg-muted/25${isProcessing ? ' pointer-events-none opacity-50' : ''}`}
+        >
</file context>
Fix with cubic

Comment on lines +307 to +316
return db.offboardingChecklistCompletion.create({
data: {
organizationId,
memberId,
templateItemId,
completedById,
isException: true,
exceptionReason: reason.trim(),
},
});

@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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant