Skip to content

[STU-103] Inspector ID verification approve/reject workflow#27

Merged
BAWES merged 7 commits into
mainfrom
feature/STU-103-id-verification-approve-reject
May 21, 2026
Merged

[STU-103] Inspector ID verification approve/reject workflow#27
BAWES merged 7 commits into
mainfrom
feature/STU-103-id-verification-approve-reject

Conversation

@BAWES
Copy link
Copy Markdown
Owner

@BAWES BAWES commented May 21, 2026

Summary

  • Add rejection_reason field to candidate_id_request Prisma schema
  • Add approveIdRequest and rejectIdRequest server actions with Zod validation (rejection reason: min 10, max 500 chars)
  • Auth-gated via requireRoleCapability("inspector", "id_review.mutate")
  • Create IdRequestActions client component with approve/reject UI
  • Update detail page to show rejection reason when status is rejected
  • Add toast notifications for approve/reject outcomes

Test plan

  • TypeScript type check passes (npx tsc --noEmit)
  • Inspector can approve/reject pending ID requests
  • Non-inspector role cannot access actions (403 due to capability gate)
  • Already-approved/rejected requests show appropriate error messages

Summary by CodeRabbit

Release Notes

  • New Features

    • Added mobile-responsive navigation with bottom tab bar
    • Introduced Civil ID information panel with status indicators
    • Added candidate missing fields display
  • Style

    • Enhanced mobile layout and responsive design for small screens
  • Improvements

    • Refined certificate processing workflow
    • Strengthened ID request status validation logic

Review Change Stack

BAWES and others added 2 commits May 21, 2026 17:34
…obile layout

- Expand candidate edit form with additional profile fields
- Add WorkLogStaffActions component for staff work log management
- Extend WorkLogAppealForm with improved UX
- Add mobile-responsive workspace shell layout (tab bar, rail collapse)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add server actions (approveIdRequest, rejectIdRequest) with Zod validation
for rejection reason, client component with confirm/reason UI, rejection
reason display in fact panel, and toast notifications. Auth-gated via
requireRoleCapability("inspector", "id_review.mutate").
@vercel
Copy link
Copy Markdown

vercel Bot commented May 21, 2026

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

Project Deployment Actions Updated (UTC)
studenthub-next Error Error May 21, 2026 8:56pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Caution

Review failed

Pull request was closed or merged during review

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7f062d09-62f6-4b7c-bb11-f58f61bae18a

📥 Commits

Reviewing files that changed from the base of the PR and between 3417b86 and 6c58cbf.

📒 Files selected for processing (2)
  • src/modules/candidates/actions.ts
  • src/modules/workspace/data.ts

Walkthrough

The PR extends ID request inspector UI to display rejection reasons conditionally, tightens approval/rejection status validation, refactors certificate and education data shapes in the candidate detail layer, narrows certificate upload input, and introduces mobile-responsive layout and panel styling throughout the application.

Changes

ID Request Inspector & Candidate Form Updates

Layer / File(s) Summary
ID Request Inspector Page UI
src/app/inspector/id-requests/[id]/page.tsx
Imports IdRequestActions and renders it with request UUID and current status. Conditionally appends "Rejection reason" fact to the batch facts panel when request status is "rejected".
Approval/Rejection Status Guards
src/modules/candidates/actions.ts
approveIdRequest and rejectIdRequest now enforce status === "pending" check before allowing operations, replacing prior status comparison logic.
Certificate Schema & Data Transformation
src/modules/candidates/actions.ts, src/modules/workspace/data.ts
addCandidateCertificate schema narrowed to only certificate_type, start_date, and end_date. getCandidateDetail Prisma selection changes to fetch company/store/staff relations instead of legacy certificate title/issuer/URL. Certificate response title now computed from company name, subtitle from certificate_type.
Education Entries Label Enrichment
src/modules/workspace/data.ts
getCandidateDetail education entries extended with universityLabel, degreeLabel, and majorLabel derived from related records.
Mobile Layout & Panel Styling
src/app/styles.css
@media (max-width: 768px) responsive rules switch shell to block layout, hide desktop rail, define fixed bottom scrollable tab bar. New civilIdPanel styles with field/photo grids and verification section. New candidateMissingFields amber alert panel with uppercase label and flex-wrapped link list.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • BAWES/studenthub-codex#24: Updates the same candidateMissingFields amber warning panel and link styling in src/app/styles.css.
  • BAWES/studenthub-codex#23: Modifies the same @media (max-width: 768px) mobile layout rules and .mobileTabBar styles in src/app/styles.css.
  • BAWES/studenthub-codex#10: Overlaps on src/modules/workspace/data.ts:getCandidateDetail education entries data shape transformation for the candidate form.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description covers summary, test plan, and key implementation details, but is missing required sections like Changes list, Type checkbox, full Checklist items, and Screenshots. Expand description to include complete Changes section with all files touched, mark Type category, verify all Checklist items are completed, and add relevant UI screenshots if applicable.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title directly corresponds to the main objective of implementing an inspector ID verification approve/reject workflow, which is reflected across all changed files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/STU-103-id-verification-approve-reject

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/app/inspector/id-requests/[id]/page.tsx (1)

20-42: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Render IdRequestActions in the page body.

IdRequestActions is imported but never mounted, so inspectors cannot approve/reject from this screen.

Suggested fix
     <WorkspaceShell
       session={session}
       eyebrow="Inspector / ID Request"
       title={`ID request ${data.request.cir_uuid.slice(0, 18)}`}
       metrics={data.metrics}
       primary={{ title: "Candidates", rows: data.candidates }}
     >
       <FactPanel
         title="Batch"
         facts={[
           { label: "Status", value: data.request.status },
           { label: "Created By", value: data.request.staff_candidate_id_request_created_byTostaff?.staff_name },
           { label: "Updated By", value: data.request.staff_candidate_id_request_updated_byTostaff?.staff_name },
           { label: "Created", value: formatDate(data.request.created_at) },
           { label: "Updated", value: formatDate(data.request.updated_at) },
           { label: "Raw Candidate IDs", value: data.request.candidate_ids },
           ...(data.request.status === "rejected" && data.request.rejection_reason
             ? [{ label: "Rejection reason", value: data.request.rejection_reason }]
             : [])
         ]}
       />
+      <IdRequestActions requestUuid={data.request.cir_uuid} currentStatus={data.request.status} />
     </WorkspaceShell>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/inspector/id-requests/`[id]/page.tsx around lines 20 - 42,
IdRequestActions is imported but never mounted, so add it into the page render
inside the WorkspaceShell children (for example directly above or below the
FactPanel) so inspectors can approve/reject; render <IdRequestActions
request={data.request} /> (or the props IdRequestActions expects) using the
existing data.request object and keep it within the WorkspaceShell children
alongside FactPanel to ensure the actions appear on the page.
🧹 Nitpick comments (1)
src/modules/candidates/WorkLogAppealForm.tsx (1)

32-100: ⚡ Quick win

Avoid keeping two WorkLogStaffActions sources.

This component is duplicated in src/modules/candidates/WorkLogStaffActions.tsx. Keeping both implementations will drift; keep one canonical component and re-export it here if needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/candidates/WorkLogAppealForm.tsx` around lines 32 - 100, There
are two copies of the WorkLogStaffActions component which will drift; remove
this duplicated implementation and instead re-export the canonical
WorkLogStaffActions implementation from the single source of truth (the other
file that currently contains WorkLogStaffActions). Keep any referenced helper
symbols (approveWorkLog, rejectWorkLog, useActionState) in the canonical module,
and update this file to only export or re-export WorkLogStaffActions (or replace
its body with a simple export { WorkLogStaffActions } from '...canonical
source...') and update any imports elsewhere to point to the canonical export.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/styles.css`:
- Around line 10310-10312: The hover rule for .candidateMissingFields li a:hover
uses an undefined token var(--fg) so it is ignored; update that rule to use the
project's defined color token for link hover (replace var(--fg) with the correct
token such as the shared link/text hover variable used across the app, e.g.,
--color-link-hover or --text-hover—whichever exists in your token palette) so
the hover color applies; locate the .candidateMissingFields li a:hover selector
and swap the token accordingly.

In `@src/modules/candidates/actions.ts`:
- Around line 813-814: The current guards only prevent re-applying the same
status by checking request.status === "approved"/"rejected"; change both checks
to enforce transitions only from pending by verifying request.status !==
"pending" and returning an error like "Only pending requests can be
approved/rejected." Update the two spots that currently read e.g. if
(request.status === "approved") ... and the analogous rejection check (the other
block around line 865-866) to use request.status !== "pending" and appropriate
messages so approvals/rejections are only allowed when request.status is
"pending".
- Around line 818-825: The status update and subsequent notification inserts
must be made atomic: locate the blocks (e.g., in approveIdRequest and
rejectIdRequest) that call prisma.candidate_id_request.update and then
prisma.notification.create/createMany (also similar blocks at the other noted
ranges) and wrap the update plus all notification creates inside a single
prisma.$transaction so either both the status change and the notifications
persist or neither do; replace standalone awaits with a single transaction call
that includes the update operation and the notification insert operations
together, preserving the same data (status, updated_by, updated_at, notification
payloads) and error handling.

In `@src/modules/candidates/WorkLogAppealForm.tsx`:
- Around line 104-113: The success toast is skipped on the first successful
resolution because prevError is initialized to state.error, so the "previous
error existed" check fails; change the prevError initialization and the
useEffect checks: initialize prevError.current to null/undefined instead of
state.error and in the effect compare prevError.current (null vs string) so that
when !pending and state.error === "" and prevError.current is a non-empty string
(or not null) you call toast.success; update references to prevError,
state.error, pending, and the useEffect callback accordingly.

In `@src/modules/candidates/WorkLogStaffActions.tsx`:
- Around line 43-45: The badge currently interpolates `currentStatus` directly
which can render "Status null"; in the WorkLogStaffActions component update the
span text logic to explicitly handle null (e.g., `currentStatus === null ? "No
status" : currentStatus === 1 ? "Approved" : currentStatus === 2 ? "Rejected" :
\`Status ${currentStatus}\``) so null shows a user-friendly label; keep the
existing data-status attribute as-is or convert to a string if needed, but
ensure `currentStatus === null` is checked before falling back to the generic
`Status ${currentStatus}`.
- Around line 20-37: The success-toast logic currently requires a prior
non-empty error (prevApproveError/prevRejectError), so first-time successful
approve/reject never shows success; change the effects to detect a transition
from pending true to false with no error instead: in the approve effect, use a
prevApprovePending ref and show success when prevApprovePending.current === true
&& !approvePending && approveState.error === ""; likewise add prevRejectPending
and in the reject effect show success when prevRejectPending.current === true &&
!rejectPending && rejectState.error === ""; update prevApprovePending.current
and prevRejectPending.current at the end of their effects and keep updating
prevApproveError/prevRejectError as needed.

---

Outside diff comments:
In `@src/app/inspector/id-requests/`[id]/page.tsx:
- Around line 20-42: IdRequestActions is imported but never mounted, so add it
into the page render inside the WorkspaceShell children (for example directly
above or below the FactPanel) so inspectors can approve/reject; render
<IdRequestActions request={data.request} /> (or the props IdRequestActions
expects) using the existing data.request object and keep it within the
WorkspaceShell children alongside FactPanel to ensure the actions appear on the
page.

---

Nitpick comments:
In `@src/modules/candidates/WorkLogAppealForm.tsx`:
- Around line 32-100: There are two copies of the WorkLogStaffActions component
which will drift; remove this duplicated implementation and instead re-export
the canonical WorkLogStaffActions implementation from the single source of truth
(the other file that currently contains WorkLogStaffActions). Keep any
referenced helper symbols (approveWorkLog, rejectWorkLog, useActionState) in the
canonical module, and update this file to only export or re-export
WorkLogStaffActions (or replace its body with a simple export {
WorkLogStaffActions } from '...canonical source...') and update any imports
elsewhere to point to the canonical export.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 41e73ea4-1b51-4a4a-a198-e7cca2501681

📥 Commits

Reviewing files that changed from the base of the PR and between 8a1dcc4 and cd49139.

📒 Files selected for processing (12)
  • prisma/schema.prisma
  • src/app/candidate/edit/page.tsx
  • src/app/inspector/id-requests/[id]/IdRequestActions.tsx
  • src/app/inspector/id-requests/[id]/page.tsx
  • src/app/styles.css
  • src/modules/candidates/CandidateEditForm.tsx
  • src/modules/candidates/CandidateProfile.tsx
  • src/modules/candidates/WorkLogAppealForm.tsx
  • src/modules/candidates/WorkLogStaffActions.tsx
  • src/modules/candidates/actions.ts
  • src/modules/workspace/NoticeToast.tsx
  • src/modules/workspace/data.ts

Comment thread src/app/styles.css
Comment thread src/modules/candidates/actions.ts Outdated
Comment on lines +818 to +825
await prisma.candidate_id_request.update({
where: { cir_uuid: requestUuid },
data: {
status: "approved",
updated_by: staffId,
updated_at: now,
},
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make status update and notifications atomic.

Each action updates candidate_id_request first, then inserts notifications. If notification insert fails, status is still mutated and the workflow becomes partially applied.

Suggested fix
-  await prisma.candidate_id_request.update({
-    where: { cir_uuid: requestUuid },
-    data: {
-      status: "approved",
-      updated_by: staffId,
-      updated_at: now,
-    },
-  });
-
-  const candidateIds = parseCandidateIdList(request.candidate_ids);
-  if (candidateIds.length > 0) {
-    await prisma.candidate_notification.createMany({
-      data: candidateIds.map((candidateId) => ({
-        cn_uuid: crypto.randomUUID(),
-        candidate_id: candidateId,
-        type: 50,
-        staff_id: staffId,
-        message: "Your ID verification request has been approved.",
-        is_new: true,
-        created_at: now,
-        updated_at: now,
-      })),
-    });
-  }
+  const candidateIds = parseCandidateIdList(request.candidate_ids);
+  await prisma.$transaction(async (tx) => {
+    await tx.candidate_id_request.update({
+      where: { cir_uuid: requestUuid },
+      data: {
+        status: "approved",
+        updated_by: staffId,
+        updated_at: now,
+      },
+    });
+
+    if (candidateIds.length > 0) {
+      await tx.candidate_notification.createMany({
+        data: candidateIds.map((candidateId) => ({
+          cn_uuid: crypto.randomUUID(),
+          candidate_id: candidateId,
+          type: 50,
+          staff_id: staffId,
+          message: "Your ID verification request has been approved.",
+          is_new: true,
+          created_at: now,
+          updated_at: now,
+        })),
+      });
+    }
+  });

Apply the same transaction pattern in rejectIdRequest.

Also applies to: 827-841, 870-878, 880-894

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/candidates/actions.ts` around lines 818 - 825, The status update
and subsequent notification inserts must be made atomic: locate the blocks
(e.g., in approveIdRequest and rejectIdRequest) that call
prisma.candidate_id_request.update and then
prisma.notification.create/createMany (also similar blocks at the other noted
ranges) and wrap the update plus all notification creates inside a single
prisma.$transaction so either both the status change and the notifications
persist or neither do; replace standalone awaits with a single transaction call
that includes the update operation and the notification insert operations
together, preserving the same data (status, updated_by, updated_at, notification
payloads) and error handling.

Comment thread src/modules/candidates/WorkLogAppealForm.tsx Outdated
Comment on lines +20 to +37
useEffect(() => {
if (approveState.error && approveState.error !== prevApproveError.current) {
toast.error("Approval failed", { description: approveState.error });
} else if (!approvePending && approveState.error === "" && prevApproveError.current !== "") {
toast.success("Work log approved", { description: "The work log entry has been approved." });
}
prevApproveError.current = approveState.error;
}, [approveState.error, approvePending]);

useEffect(() => {
if (rejectState.error && rejectState.error !== prevRejectError.current) {
toast.error("Rejection failed", { description: rejectState.error });
} else if (!rejectPending && rejectState.error === "" && prevRejectError.current !== "") {
toast.success("Work log rejected", { description: "The work log entry has been rejected." });
setMode("idle");
}
prevRejectError.current = rejectState.error;
}, [rejectState.error, rejectPending]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Approve/reject success toasts can be missed on first success.

Lines 23 and 32 require a prior non-empty error, so first-time successful approve/reject submits won't show success toasts.

Proposed fix
   const prevApproveError = useRef(approveState.error);
   const prevRejectError = useRef(rejectState.error);
+  const prevApprovePending = useRef(false);
+  const prevRejectPending = useRef(false);

   useEffect(() => {
-    if (approveState.error && approveState.error !== prevApproveError.current) {
-      toast.error("Approval failed", { description: approveState.error });
-    } else if (!approvePending && approveState.error === "" && prevApproveError.current !== "") {
-      toast.success("Work log approved", { description: "The work log entry has been approved." });
+    if (prevApprovePending.current && !approvePending) {
+      if (approveState.error) {
+        toast.error("Approval failed", { description: approveState.error });
+      } else {
+        toast.success("Work log approved", { description: "The work log entry has been approved." });
+      }
     }
     prevApproveError.current = approveState.error;
+    prevApprovePending.current = approvePending;
   }, [approveState.error, approvePending]);

   useEffect(() => {
-    if (rejectState.error && rejectState.error !== prevRejectError.current) {
-      toast.error("Rejection failed", { description: rejectState.error });
-    } else if (!rejectPending && rejectState.error === "" && prevRejectError.current !== "") {
-      toast.success("Work log rejected", { description: "The work log entry has been rejected." });
-      setMode("idle");
+    if (prevRejectPending.current && !rejectPending) {
+      if (rejectState.error) {
+        toast.error("Rejection failed", { description: rejectState.error });
+      } else {
+        toast.success("Work log rejected", { description: "The work log entry has been rejected." });
+        setMode("idle");
+      }
     }
     prevRejectError.current = rejectState.error;
+    prevRejectPending.current = rejectPending;
   }, [rejectState.error, rejectPending]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/candidates/WorkLogStaffActions.tsx` around lines 20 - 37, The
success-toast logic currently requires a prior non-empty error
(prevApproveError/prevRejectError), so first-time successful approve/reject
never shows success; change the effects to detect a transition from pending true
to false with no error instead: in the approve effect, use a prevApprovePending
ref and show success when prevApprovePending.current === true && !approvePending
&& approveState.error === ""; likewise add prevRejectPending and in the reject
effect show success when prevRejectPending.current === true && !rejectPending &&
rejectState.error === ""; update prevApprovePending.current and
prevRejectPending.current at the end of their effects and keep updating
prevApproveError/prevRejectError as needed.

Comment thread src/modules/candidates/WorkLogStaffActions.tsx Outdated
@BAWES
Copy link
Copy Markdown
Owner Author

BAWES commented May 21, 2026

DevRel Review

Status: Changes requested — 6 actionable CodeRabbit findings + merge conflict

Critical: IdRequestActions imported but never mounted

CodeRabbit flagged that IdRequestActions is imported in page.tsx but never rendered in the JSX. This means inspectors cannot approve or reject ID requests from the detail page — the core feature of this PR.

Other findings from CodeRabbit:

  1. Status transition validation — guards check for re-applying the same status but should enforce transitions only from "pending"
  2. Missing transaction wrapping — status update and notification inserts should be wrapped in prisma.$transaction for atomicity
  3. Duplicate WorkLogStaffActions — component is duplicated in both WorkLogAppealForm.tsx and WorkLogStaffActions.tsx; keep one canonical source
  4. CSS token bug.candidateMissingFields li a:hover uses undefined var(--fg) token
  5. Toast effect logic — success toasts may not fire on first successful action due to prevError initialization

Merge conflict

This PR has a merge conflict with main — resolve before requesting re-review.

Vercel

Preview deploy is failing.

Next step: Fix the unmounted IdRequestActions (blocking bug), resolve the merge conflict, then address the remaining CodeRabbit findings.

- Mount IdRequestActions in inspector ID request detail page so
  inspectors can approve/reject requests
- Add server-side status guard: only allow approve/reject transitions
  from "pending" status (previously only checked for already-approved
  or already-rejected, allowing invalid transitions)
- Replace undefined CSS variable var(--fg) with var(--foreground)
  in .candidateMissingFields li a:hover rule

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/modules/candidates/actions.ts (1)

807-841: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Make the pending-status check part of the write.

Both actions still do a read-then-write sequence, so two inspectors can race: each reads "pending", then both apply different terminal states and enqueue notifications. Wrap the status change and notification insert in one transaction, and make the mutation itself conditional on status: "pending" so only one caller can win.

Suggested pattern
-  await prisma.candidate_id_request.update({
-    where: { cir_uuid: requestUuid },
-    data: {
-      status: "approved",
-      updated_by: staffId,
-      updated_at: now,
-    },
-  });
-
   const candidateIds = parseCandidateIdList(request.candidate_ids);
-  if (candidateIds.length > 0) {
-    await prisma.candidate_notification.createMany({
-      data: candidateIds.map((candidateId) => ({
-        cn_uuid: crypto.randomUUID(),
-        candidate_id: candidateId,
-        type: 50,
-        staff_id: staffId,
-        message: "Your ID verification request has been approved.",
-        is_new: true,
-        created_at: now,
-        updated_at: now,
-      })),
-    });
+  const applied = await prisma.$transaction(async (tx) => {
+    const result = await tx.candidate_id_request.updateMany({
+      where: { cir_uuid: requestUuid, status: "pending" },
+      data: {
+        status: "approved",
+        updated_by: staffId,
+        updated_at: now,
+      },
+    });
+
+    if (result.count !== 1) return false;
+
+    if (candidateIds.length > 0) {
+      await tx.candidate_notification.createMany({
+        data: candidateIds.map((candidateId) => ({
+          cn_uuid: crypto.randomUUID(),
+          candidate_id: candidateId,
+          type: 50,
+          staff_id: staffId,
+          message: "Your ID verification request has been approved.",
+          is_new: true,
+          created_at: now,
+          updated_at: now,
+        })),
+      });
+    }
+
+    return true;
+  });
+
+  if (!applied) {
+    return { error: "This request has already been processed." };
   }

Apply the same pattern in rejectIdRequest.

Also applies to: 859-894

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/candidates/actions.ts` around lines 807 - 841, Replace the
read-then-write for approving the ID request with an atomic transaction that
performs a conditional mutation (only where cir_uuid = requestUuid AND status =
"pending") and then inserts notifications, so only one caller can win;
specifically, remove the initial prisma.candidate_id_request.findUnique and
prisma.candidate_id_request.update calls and instead use prisma.$transaction to
run prisma.candidate_id_request.updateMany({ where: { cir_uuid: requestUuid,
status: "pending" }, data: { status: "approved", updated_by: staffId,
updated_at: now } }) and, if updateMany.count > 0, include
prisma.candidate_notification.createMany(...) in the same transaction; if
updateMany.count is 0 return the same pending-status error. Apply the identical
pattern in rejectIdRequest (use updateMany with status filter and conditional
notification insert inside a single transaction).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/modules/candidates/actions.ts`:
- Around line 807-841: Replace the read-then-write for approving the ID request
with an atomic transaction that performs a conditional mutation (only where
cir_uuid = requestUuid AND status = "pending") and then inserts notifications,
so only one caller can win; specifically, remove the initial
prisma.candidate_id_request.findUnique and prisma.candidate_id_request.update
calls and instead use prisma.$transaction to run
prisma.candidate_id_request.updateMany({ where: { cir_uuid: requestUuid, status:
"pending" }, data: { status: "approved", updated_by: staffId, updated_at: now }
}) and, if updateMany.count > 0, include
prisma.candidate_notification.createMany(...) in the same transaction; if
updateMany.count is 0 return the same pending-status error. Apply the identical
pattern in rejectIdRequest (use updateMany with status filter and conditional
notification insert inside a single transaction).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2392c597-af77-45ee-838e-81ffc5ea80f1

📥 Commits

Reviewing files that changed from the base of the PR and between cd49139 and d4cd4d3.

📒 Files selected for processing (3)
  • src/app/inspector/id-requests/[id]/page.tsx
  • src/app/styles.css
  • src/modules/candidates/actions.ts

Resolved conflicts in candidate edit page, styles, CandidateEditForm,
WorkLogStaffActions, and actions.ts — keeping STU-103/STU-171 changes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Taking main's CandidateEditForm, WorkLogAppealForm, WorkLogStaffActions,
and actions.ts, then re-applying STU-171 status guards on approveIdRequest
and rejectIdRequest (only allow transitions from "pending" status).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…ma mismatch

- Add educationEntries, degrees, majors to candidate edit page props
- Add universityLabel/degreeLabel/majorLabel to educationEntries mapping
- Remove certificate_title, certificate_issuer, certificate_url from queries
  (fields don't exist in Prisma schema)
- Remove as WorkLogAction casts on form action props

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ns) [STU-103]

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/modules/candidates/actions.ts`:
- Around line 498-499: The current certificateSchema's certificate_type uses
z.string().transform((v) => v === "true").pipe(z.boolean()) which silently
coerces any non-"true" string to false; update certificate_type to explicitly
accept only "true" or "false" (e.g., use z.enum(["true","false"]) or
z.string().refine(v => v === "true" || v === "false")) and then transform to a
boolean (map "true" -> true, "false" -> false) so invalid values fail
validation; locate certificateSchema and the certificate_type entry to make this
change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c624f8d7-5db6-4ec7-9c3f-cbb2b0b80c5e

📥 Commits

Reviewing files that changed from the base of the PR and between 13dafe2 and 3417b86.

📒 Files selected for processing (4)
  • src/app/candidate/edit/page.tsx
  • src/modules/candidates/WorkLogStaffActions.tsx
  • src/modules/candidates/actions.ts
  • src/modules/workspace/data.ts
✅ Files skipped from review due to trivial changes (1)
  • src/modules/candidates/WorkLogStaffActions.tsx

Comment on lines 498 to 499
const certificateSchema = z.object({
certificate_type: z.string().transform((v) => v === "true").pipe(z.boolean()),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Zod 3.x, does z.string().transform(v => v === "true").pipe(z.boolean())accept arbitrary strings like "foo" and parse them asfalse instead of failing validation?

💡 Result:

Yes. In Zod 3.x, your schema will not fail on arbitrary strings; it will transform them to a boolean based on your transform function. Given: z.string().transform(v => v === "true").pipe(z.boolean()) - z.string() accepts any string input. - .transform(...) runs for every accepted string and returns a boolean value: it returns true only when the input string is exactly "true", and returns false for any other string (including "foo"). - .pipe(z.boolean()) then validates the already-transformed value with z.boolean(). Since the transform always outputs a real boolean, validation always succeeds. So for input "foo", the transform yields false, and the final parse succeeds as false (not a validation error). This is consistent with Zod’s general behavior: transforms accept anything and perform a unidirectional transformation, and they’re commonly used with pipes to validate the transformed result rather than re-validate the original input [1]. If you instead want Zod to fail on strings other than "true"/"false", you must implement that in the transform (e.g., throw/add an issue for other values) or use a literal/enum-based schema rather than a boolean-valued equality transform [2].

Citations:


Reject non-"true"/"false" certificate_type values (prevent silent coercion)

File: src/modules/candidates/actions.ts (lines 498-499)
z.string().transform((v) => v === "true").pipe(z.boolean()) accepts any string; it transforms unknown values (e.g., "foo") to false, so validation never fails.

Suggested fix
 const certificateSchema = z.object({
-  certificate_type: z.string().transform((v) => v === "true").pipe(z.boolean()),
+  certificate_type: z.enum(["true", "false"]).transform((v) => v === "true"),
   start_date: z.string().max(10).optional(),
   end_date: z.string().max(10).optional(),
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const certificateSchema = z.object({
certificate_type: z.string().transform((v) => v === "true").pipe(z.boolean()),
const certificateSchema = z.object({
certificate_type: z.enum(["true", "false"]).transform((v) => v === "true"),
start_date: z.string().max(10).optional(),
end_date: z.string().max(10).optional(),
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/candidates/actions.ts` around lines 498 - 499, The current
certificateSchema's certificate_type uses z.string().transform((v) => v ===
"true").pipe(z.boolean()) which silently coerces any non-"true" string to false;
update certificate_type to explicitly accept only "true" or "false" (e.g., use
z.enum(["true","false"]) or z.string().refine(v => v === "true" || v ===
"false")) and then transform to a boolean (map "true" -> true, "false" -> false)
so invalid values fail validation; locate certificateSchema and the
certificate_type entry to make this change.

@BAWES BAWES merged commit e060590 into main May 21, 2026
7 of 9 checks passed
@BAWES BAWES deleted the feature/STU-103-id-verification-approve-reject branch May 21, 2026 20:59
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