Skip to content

[STU-25] Staff request fulfillment pipeline#6

Merged
BAWES merged 5 commits into
mainfrom
feature/STU-25-staff-request-fulfillment
May 21, 2026
Merged

[STU-25] Staff request fulfillment pipeline#6
BAWES merged 5 commits into
mainfrom
feature/STU-25-staff-request-fulfillment

Conversation

@BAWES
Copy link
Copy Markdown
Owner

@BAWES BAWES commented May 20, 2026

Summary

  • Staff request pipeline view with candidate matching, suggestions, invitations, applications, interviews, and stories/work trail
  • Candidate matching against request skills from prod-clone data (skill-based DB query, excludes already-involved candidates)
  • Suggestion creation (note + suggestion record in a transaction)
  • Invitation send/track with status transitions (created → responded/declined)
  • Interview schedule/status management (schedule, complete, cancel)
  • Application status transitions (shortlist/reject with notes)
  • Candidate notes, tags, and warnings management
  • Story/work trail log (create + complete/cancel)
  • Legacy-compatible writes: uuid prefixes, timestamps, staff_id, company_id
  • Staff and admin routing both wired with capability-scoped access
  • Candidate self-service: profile edit, invitation response, work log appeal, payments keyboard shortcut

Files changed

31 files, +3760 / -111 — server actions, React components, data layer, routing pages, styles

Key new modules

  • src/modules/requests/RequestFulfillmentOS.tsx — main pipeline UI with 6 pipeline cards
  • src/modules/requests/MatchActions.tsx — suggestion + invitation forms for matched candidates
  • src/modules/requests/StageActions.tsx — status transition buttons for all pipeline stages
  • src/modules/requests/actions.tsaddCandidateSuggestionAction
  • src/modules/requests/candidate-actions.ts — notes, tags, warnings CRUD
  • src/modules/requests/invitation-actions.ts — create + update invitation status
  • src/modules/requests/interview-actions.ts — schedule + update interview
  • src/modules/requests/story-actions.ts — create story + update story status
  • src/modules/requests/application-actions.ts — application status transitions
  • src/modules/workspace/data.ts:1298getRequestDetail() data layer with parallel queries, skill matching, pipeline metrics

Server actions pattern

All mutations: validate session and capability → verifies request ownership (staff-scoped) → checks for duplicates → writes in prisma.$transaction() → revalidates paths → redirects with notice param.

Test plan

  • Verify staff can view assigned request detail page
  • Verify candidate matching shows skill-aligned candidates
  • Verify suggestion creation adds note + suggestion record
  • Verify invitation creation and status transitions
  • Verify interview scheduling and complete/cancel
  • Verify application shortlist/reject with notes
  • Verify candidate notes, tags, warnings CRUD
  • Verify story log and status transitions
  • Verify admin sees all requests, staff only assigned
  • Run npx tsc --noElim for zero type errors
  • Run next build for successful build

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Staff-facing candidate management: add/edit/remove notes, tags, warnings, skills, and status controls.
    • Request matching: suggest and invite candidates; create and manage invitations, applications, interviews, stories, and evaluations via inline actions.
    • Candidate portal: payments history + detail view, CV export download, improved profile edit/remove flows.
    • Hub/App: new hub UI, role guides, and command/shortcut support.
  • Documentation

    • Added Playwright smoke-test coverage guide and parity checklist.
  • Tests

    • Added Playwright e2e smoke test suites and fixture discovery/validation tooling.
  • Chores

    • Updated .gitignore to exclude test artifacts.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 840e076a-58e6-4f18-aa02-b5c1db111793

📥 Commits

Reviewing files that changed from the base of the PR and between c78c143 and 7c1f44d.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (27)
  • docs/migration/test-coverage.md
  • e2e/fixtures/auth.ts
  • e2e/smoke/candidate-search.spec.ts
  • e2e/smoke/candidate-self-service.spec.ts
  • e2e/smoke/login.spec.ts
  • e2e/smoke/request-desk.spec.ts
  • e2e/smoke/role-portals.spec.ts
  • package.json
  • playwright.config.ts
  • scripts/fixtures/discover.mjs
  • scripts/fixtures/validate.mjs
  • src/app/app/layout.tsx
  • src/app/app/page.tsx
  • src/app/candidate/payments/[id]/page.tsx
  • src/modules/candidates/CandidateEditForm.tsx
  • src/modules/candidates/ExportCVsForm.tsx
  • src/modules/candidates/actions.ts
  • src/modules/candidates/export-actions.ts
  • src/modules/candidates/search.ts
  • src/modules/candidates/worklog-actions.ts
  • src/modules/hub/HubContent.tsx
  • src/modules/requests/MatchActions.tsx
  • src/modules/requests/RequestFulfillmentOS.tsx
  • src/modules/requests/StageActions.tsx
  • src/modules/requests/pipeline-actions.ts
  • src/modules/workspace/WorkTabs.tsx
  • src/modules/workspace/data.ts

Walkthrough

This PR introduces staff-side candidate management capabilities, enhances request fulfillment with per-row conditional status-transition actions across applications/interviews/invitations/stories, and refines candidate search/workspace scoping for staff-assigned candidates. It also improves candidate payments page navigation and adds test artifact exclusions.

Changes

Staff Candidate Management System

Layer / File(s) Summary
Staff Access Control Helpers and Candidate Management Actions
src/modules/candidates/actions.ts
Added shared staff access verification helper and 11 server actions for staff to create/edit/remove candidate notes, tags, warnings, skills, plus set approval status, profile completion, and civil verification; all enforce capability checks and use Prisma transactions.
Candidate Search Scoping for Staff-Assigned Candidates
src/modules/candidates/search.ts
Updated candidate search workspace to scope selected candidate IDs and open-tab queries using staffCandidateIds from candidate_work_history, replacing visibility-dependent scoping to enforce staff-only access.
Workspace Staff Candidate Metrics Scoping
src/modules/workspace/data.ts
Staff workspace "Candidates" metric now counts only candidates assigned to the staff member via getCandidateIdsForStaff, using a safe empty-list fallback.

Request Fulfillment with Row-Level Status Actions

Layer / File(s) Summary
Stage-Based Status Action Components
src/modules/requests/StageActions.tsx
Four new client components render conditional form-based status-transition actions: ApplicationStatusActions (shortlist/reject with optional notes), InterviewStatusActions (complete/cancel with optional notes), InvitationStatusActions (accept/decline), and StoryStatusActions (complete/cancel).
Candidate Matching and Invitation Form Components
src/modules/requests/MatchActions.tsx
SuggestForm and InviteForm client components submit candidate suggestions and invitations with request UUID, candidate ID, and optional reason/suggestion link via role-appropriate forms.
Interview Scheduling and Status Update Actions
src/modules/requests/interview-actions.ts
scheduleInterviewAction creates interview records with candidate/date validation; updateInterviewAction updates status and notes; both enforce capability checks, staff request ownership, and Prisma transactions.
Invitation Creation and Status Management Actions
src/modules/requests/invitation-actions.ts
createInvitationAction validates and creates invitations with duplicate prevention, optional suggestion link, and parent request timestamp update; updateInvitationStatusAction updates invitation status with audit fields; both include staff ownership checks and Prisma transactions.
Story Creation and Status Update Actions
src/modules/requests/story-actions.ts
createStoryAction creates story records with optional linked notes and role-scoped request lookup; updateStoryStatusAction updates story status and request timestamp; both enforce capability checks and Prisma transactions.
Request-Scoped Candidate Note and Tag Management Actions
src/modules/requests/candidate-actions.ts
Four server actions for staff to add/remove candidate notes, tags, and warnings during request fulfillment with optional request context for routing and Prisma transactions.
Application Status Transition with Staff Ownership Check
src/modules/requests/application-actions.ts
transitionApplicationAction now enforces staff request ownership verification before updating application status; conditionally creates associated notes and executes updates in Prisma transaction.
Request Fulfillment OS Integration with Per-Row Actions
src/modules/requests/RequestFulfillmentOS.tsx
Refactored to centralize requestUuid, integrate MatchActions components, add per-row actions callbacks for invitations/applications/interviews/stories pipeline cards, add "Log update" story form, and generalize RequestRows to accept optional actions callback for row-level action rendering.

Candidates: Edit, Export, and Worklog

Layer / File(s) Summary
Candidate input parsing and document upload
src/modules/candidates/actions.ts, src/modules/candidates/CandidateEditForm.tsx
Tightened numeric/date parsing, changed document upload field naming to file_{type}, and refactored remove-item UI to use visible buttons targeting hidden forms.
Export Candidate CV bundle
src/modules/candidates/export-actions.ts, src/modules/candidates/ExportCVsForm.tsx
Added server action to render an HTML candidate CV bundle for selected candidate IDs and a client form component to trigger export.
Work-log moderation actions
src/modules/candidates/worklog-actions.ts
Added staff-capability-gated actions to approve/reject work logs, resolve appeals, add feedback, and append appeal update notes; all enforce candidate-scoped ownership for non-admins and revalidate relevant routes.

Candidate Portal Navigation

Layer / File(s) Summary
Candidate Payments Page Row Navigation
src/app/candidate/payments/page.tsx
Updated CandidatePaymentsPage to import Route type and wire DataTable rowHref for typed per-row links to individual payment detail routes.
Candidate Payment Detail Page
src/app/candidate/payments/[id]/page.tsx
Added dynamic server page to show payment/transfer detail using getCandidateTransferDetail, conditional transfer/invoice panels, and auth gating.

Request Pipeline: Evaluations and Notes

Layer / File(s) Summary
Interview evaluations and notes
src/modules/requests/pipeline-actions.ts
Added createInterviewEvaluationAction and addInterviewEvaluationNoteAction, plus re-exports of related action functions to centralize pipeline actions.

Configuration and Infrastructure

Layer / File(s) Summary
Test Artifact Ignore Rules
.gitignore
Added gitignore entries for playwright test report and results directories.
Playwright and package scripts
playwright.config.ts, package.json
Added Playwright config, test scripts, and devDependency for @playwright/test.

Sequence Diagram(s)

sequenceDiagram
  participant UI as RequestFulfillmentOS
  participant Rows as RequestRows
  participant Actions as StageActions (client)
  participant Server as ServerAction (e.g., invitation/interview/story)
  participant DB as Prisma
  UI->>Rows: render rows + actions(row)
  Rows->>Actions: render per-row forms
  Actions->>Server: submit form (server action)
  Server->>DB: validate + write (transaction)
  DB-->>Server: success
  Server->>Server: revalidatePath(list/detail)
  Server->>UI: redirect with notice
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • BAWES/studenthub-codex#5: Adds the initial CandidatePaymentsPage component; this PR enhances its DataTable row navigation with typed route links.
  • BAWES/studenthub-codex#6: Implements related request fulfillment pipeline components and server actions that overlap with the request-stage changes in this PR.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title '[STU-25] Staff request fulfillment pipeline' clearly and specifically describes the main feature being implemented: a staff-facing request fulfillment pipeline.
Description check ✅ Passed The PR description provides a detailed summary of changes, lists key new modules, explains the server actions pattern, includes a comprehensive test plan with specific verification steps, and notes AI assistance. However, the TypeScript and build validation checkboxes are not marked as completed.
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-25-staff-request-fulfillment

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: 19

🧹 Nitpick comments (2)
src/modules/workspace/WorkTabs.tsx (1)

89-104: 💤 Low value

Potential stale closure when computing redirect target.

remaining is computed from the closure-captured tabs, while setTabs uses the functional updater's prev. Both should be equivalent due to tabs in the dependency array, but this pattern is fragile—if dependencies are ever changed, or if multiple rapid closes occur before re-render, the redirect target may diverge from the persisted state.

A more robust approach computes the redirect target inside the setter and triggers navigation via a ref or effect:

♻️ Suggested refactor
+const pendingRedirect = useRef<string | null>(null);
+
+useEffect(() => {
+  if (pendingRedirect.current) {
+    router.push(pendingRedirect.current as Route);
+    pendingRedirect.current = null;
+  }
+}, [tabs, router]);

 const closeTab = useCallback(
   (path: string) => {
     setTabs((prev) => {
       const next = prev.filter((t) => t.path !== path);
       writeTabs(next);
+      if (pathname === path && next.length > 0) {
+        pendingRedirect.current = next[0].path;
+      }
       return next;
     });
-    if (pathname === path) {
-      const remaining = tabs.filter((t) => t.path !== path);
-      if (remaining.length > 0) {
-        router.push(remaining[0].path as Route);
-      }
-    }
   },
-  [pathname, router, tabs]
+  [pathname]
 );
🤖 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/workspace/WorkTabs.tsx` around lines 89 - 104, The closeTab
callback uses the outer `tabs` array to compute `remaining` for redirect, which
can create a stale-closure bug; change logic in `closeTab` (the function that
calls `setTabs`, `writeTabs`, and uses `router.push`) to compute the next active
path from the updater's `prev` value (inside the `setTabs(prev => { ... })`),
store that next path to a ref (or return it from the updater), call `writeTabs`
with the new tabs, and then perform `router.push(nextPath)` after the state
update using the ref or an effect so the redirected path always matches the
persisted tabs; keep references to `closeTab`, `setTabs`, `writeTabs`,
`pathname`, `router`, and `tabs` when locating the change.
src/modules/requests/RequestFulfillmentOS.tsx (1)

132-149: ⚡ Quick win

Reuse shared match action components to avoid drift.

This section duplicates the form payload/action wiring already centralized in MatchActions.tsx. Reusing those components reduces divergence risk.

Proposed refactor
+import { InviteForm, SuggestForm } from "`@/modules/requests/MatchActions`";
@@
-                <div className="matchActionsRow">
-                  <form className="suggestionForm" action={addCandidateSuggestionAction}>
-                    <input name="request_uuid" type="hidden" value={requestUuid} />
-                    <input name="candidate_id" type="hidden" value={candidate.id} />
-                    <Input name="reason" placeholder="Why this candidate fits" />
-                    <Button type="submit">
-                      <Send aria-hidden="true" />
-                      Suggest
-                    </Button>
-                  </form>
-                  <form action={createInvitationAction}>
-                    <input name="request_uuid" type="hidden" value={requestUuid} />
-                    <input name="candidate_id" type="hidden" value={candidate.id} />
-                    <Button type="submit" variant="outline" size="sm">
-                      <Plus aria-hidden="true" />
-                      Invite
-                    </Button>
-                  </form>
-                </div>
+                <div className="matchActionsRow">
+                  <SuggestForm requestUuid={requestUuid} candidateId={candidate.id} />
+                  <InviteForm requestUuid={requestUuid} candidateId={candidate.id} />
+                </div>
🤖 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/requests/RequestFulfillmentOS.tsx` around lines 132 - 149, The
duplicated inline forms in RequestFulfillmentOS.tsx (the suggestion and invite
<form> blocks wired to addCandidateSuggestionAction and createInvitationAction)
should be replaced with the shared match action components exported from
MatchActions.tsx to avoid drift; import the relevant components from
MatchActions (e.g., SuggestionForm / InviteButton or whatever named exports
exist) and pass the requestUuid and candidate.id as props (or map to the
components' expected prop names) so the payload/action wiring stays centralized,
then remove the duplicated form markup in RequestFulfillmentOS.
🤖 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 151-156: The MIME check currently skips "video" by using the
condition (type !== "cv" && type !== "video"), so add "video" to the MIME
validation path: change the logic that uses typeConfig.mime to validate
file.type for all non-CV types (including "video") and return the same
structured error when file.type is missing from typeConfig.mime; reference the
variables and expressions in this diff (type, file.type, typeConfig.mime) and
update the error message for the video branch to reflect accepted MIME types
(use typeConfig.mime or a descriptive message for video formats).
- Around line 386-399: The startYear/endYear are computed with
Number(formData.get(...)) so blank inputs become 0; change to read the raw
trimmed strings (e.g. const startYearStr = String(formData.get("startYear") ??
"").trim(), const endYearStr = ...) and only convert to a number for persistence
when the string is non-empty and parses to a valid integer within your expected
range (use parseInt and Number.isInteger/Number.isFinite or isNaN checks).
Update the prisma.candidate_experience.create call (in this block) to set
start_year and end_year to the parsed integer or undefined when the input is
blank/invalid so empty fields are not saved as 0.
- Around line 82-132: The code writes sensitive candidate files to a public web
folder (UPLOAD_DIR) and returns direct URLs; change storage to a non-public
location and serve files via authorized handlers or signed URLs instead: update
the UPLOAD_DIR constant to a private path (e.g., outside the webroot), modify
saveUpload (function saveUpload(candidateId, field, file, typeConfig)) to write
files to that private directory and return an internal storage key or path (not
a public /uploads URL), and update any callers to fetch files through a
new/existing protected download endpoint that enforces auth/authorization and
can generate signed URLs; also ensure saved files have restrictive permissions
and keep the same validation checks (ext/size) in saveUpload.
- Around line 45-49: The current nullableIntField allows non-integer/negative
values and the birthdate parsing can produce invalid Date objects before a
Prisma write; update nullableIntField to only accept string representations of
positive whole integers (e.g., test with a /^\d+$/ regex, parse with Number and
return undefined for zero/negative/NaN) and change the birth-date parsing logic
to explicitly validate the Date (create the Date from the input and check
isFinite(date.getTime()) or use a strict YYYY-MM-DD regex) returning undefined
or throwing a validation error for invalid dates so nothing invalid is sent to
Prisma; apply these checks where nullableIntField is used and where the Date is
constructed so IDs and birth dates are guaranteed valid before DB write.

In `@src/modules/candidates/CandidateEditForm.tsx`:
- Around line 175-203: The form uses repeated input names so
formData.get("type") / formData.get("file") can return the first entry; update
the DocumentUpload component and the upload handling to use unique names per
document type (e.g., file_<type> and type_<type>) or else submit arrays and read
using formData.getAll(...) in uploadAction. Concretely, change DocumentUpload to
render inputs that include the document type in the input name (reference
DocumentUpload component and the form with action={uploadAction}), and update
uploadAction to look up files by those unique names (or iterate expected types
and call formData.get("file_"+type)) so each upload maps reliably to its
document type. Ensure the submit button and uploadPending logic remain
unchanged.
- Around line 204-218: The skills list currently nests a child <form> (using
removeSkillAction) inside the parent add-skill <form> (rendered in
CandidateEditForm), which is invalid HTML and causes broken submissions; fix by
removing the nested <form> inside skills.map and instead either render a sibling
<form> per skill (outside the parent add-skill form) or keep a hidden/sibling
<form> for the remove action and attach the remove button via the HTML form
attribute (e.g., form="remove-skill-{s.id}"), ensuring you reference
removeSkillAction and removeSkillPending and keep the same hidden input
name="skillId" value={s.id} so submissions call removeSkillAction correctly.

In `@src/modules/candidates/CandidateProfile.tsx`:
- Around line 212-237: The "Civil ID photos" readiness currently marks as done
if either side exists; update the items array in the profile completion function
(where items is defined in CandidateProfile.tsx) so the "Civil ID photos"
entry's done predicate requires both c?.candidate_civil_photo_front and
c?.candidate_civil_photo_back to be present (use a logical AND) while keeping
its label/field ("Civil ID photos (front/back)") unchanged so the missing list
still reports that field when either side is absent.

In `@src/modules/requests/application-actions.ts`:
- Around line 24-44: Before mutating the application, verify the current staff
owns the request: query prisma.request.findFirst({ where: { request_uuid:
requestUuid, staff_id: staffId } }) (use session.id -> staffId) and if not found
redirect to `${detailPath}?notice=not-found`; only after that, build the
operations array and call prisma.request_application.update and
prisma.request.update. This ensures the existing
prisma.request_application.update and prisma.request.update calls are executed
only when the request with requestUuid is owned by the current staff.

In `@src/modules/requests/candidate-actions.ts`:
- Around line 105-122: The removeCandidateTagAction currently validates only
tag_id and calls prisma.candidate_tag.update by tag_id which allows removing
that tag globally; update the action to also validate candidate_id (ensure
Number.isInteger(candidateId) && candidateId > 0 and redirect on invalid) and
scope the DB change to both identifiers — e.g., replace the single-key update
with an updateMany or an update using the composite key (where: { tag_id: tagId,
candidate_id: candidateId } or the model's composite unique key) so only the tag
association for that candidate is marked deleted; keep the existing
returnPath/redirect behavior.

In `@src/modules/requests/interview-actions.ts`:
- Around line 21-23: The current guard checks requestUuid, candidateId and
non-empty interviewAt but does not validate the datetime string, so invalid
values still reach the Date construction (around the Date(...) call at line 52);
update the initial validation (the if that references requestUuid, candidateId,
interviewAt) to parse/validate interviewAt (e.g. const parsed = new
Date(interviewAt); if (isNaN(parsed.getTime())) ) and treat it as invalid:
redirect to the same detailPath with a clear notice (e.g.
notice=invalid-interview-at) and return after redirect to prevent further
execution; apply the same parsed/valid check before any Date construction or
persistence paths that use interviewAt (use the interviewAt variable and the
Date constructor checks to locate the spots).
- Around line 49-50: Remove the fallback that synthesizes application_uuid from
interviewUuid; instead ensure the payload uses only the real applicationUuid or
omits the property entirely. Replace the expression application_uuid:
applicationUuid ?? interviewUuid with either application_uuid: applicationUuid
(no fallback) or conditionally add the application_uuid key only when
applicationUuid is present (e.g., build the object and set application_uuid only
if applicationUuid is defined) so that interviewUuid is never written into
application_uuid.
- Around line 86-93: The current existence check for an interview
(prisma.request_interview.findFirst) in updateInterviewAction only verifies
interview UUIDs but doesn't ensure the request is owned by the acting staff
user; modify the query to also constrain ownership by the current staff user
(e.g., add a where condition comparing request.request_staff_uuid or
join/include the related request and require request.request_staff_uuid ===
currentStaffUuid) so that if no row is returned you redirect
`${detailPath}?notice=not-found`; use the same identifiers
(updateInterviewAction, prisma.request_interview.findFirst,
request_interview_uuid, request_uuid, request.request_staff_uuid or equivalent)
to locate and update the check.

In `@src/modules/requests/invitation-actions.ts`:
- Around line 89-114: Before updating invitation and request, verify the current
staff actually owns the request: after fetching the invitation (invitation
findFirst) also load the related request owner (e.g.,
request.request_assigned_to_staff_id or equivalent) and compare it to
Number(session.id) when session.role === "staff"; if the IDs don't match, abort
(redirect to detailPath?notice=forbidden or similar) and do not call
prisma.invitation.update or prisma.request.update. Make this check near the
existing invitation lookup (the block using session.role, session.id,
prisma.invitation.findFirst, and before the prisma.$transaction) so ownership is
enforced for prisma.invitation.update and prisma.request.update.

In `@src/modules/requests/MatchActions.tsx`:
- Around line 6-7: Update the two internal imports in MatchActions.tsx to use
the project path alias instead of relative paths: replace the import of
addCandidateSuggestionAction from "./actions" with "`@/modules/requests/actions`"
(or the correct aliased path that exposes addCandidateSuggestionAction) and
replace the import of createInvitationAction from "./invitation-actions" with
"`@/modules/requests/invitation-actions`" (or the correct aliased path that
exposes createInvitationAction); ensure the imported symbol names
(addCandidateSuggestionAction, createInvitationAction) remain unchanged.

In `@src/modules/requests/RequestFulfillmentOS.tsx`:
- Around line 8-16: Update the internal imports in RequestFulfillmentOS.tsx to
use the project "`@/`..." alias instead of relative paths: replace imports for
addCandidateSuggestionAction, createInvitationAction, createStoryAction and the
ApplicationStatusActions, InterviewStatusActions, InvitationStatusActions,
StoryStatusActions with their corresponding "`@/modules/requests/`..." alias paths
(keeping the same exported names), ensuring the import specifiers point to the
aliased module locations used across the codebase.

In `@src/modules/requests/StageActions.tsx`:
- Around line 35-42: The icon-only Button using MessageSquare and toggling
showNote via setShowNote has no accessible name; add an explicit accessible
label and state: give the Button an aria-label (e.g., aria-label={`Toggle note
${showNote ? 'close' : 'open'}`}) and also include an appropriate state
attribute such as aria-expanded={showNote} or aria-pressed={showNote} to reflect
the toggle; apply the same change to the other identical icon-only Button that
also calls setShowNote/showNote so both toggles are accessible to screen
readers.
- Around line 6-9: Replace the four relative imports in StageActions.tsx
(transitionApplicationAction, updateInterviewAction,
updateInvitationStatusAction, updateStoryStatusAction) with the project
path-alias form (use "`@/`..." instead of "./...") so internal module imports
follow the codebase convention; update each import statement to reference the
same module symbol but via the "`@/`..." alias and save/compile to ensure paths
resolve.
- Line 31: The submit buttons currently call setShowNote(false) in their onClick
handlers (see the Button elements around the submit markup), which can unmount
the note textarea before form serialization and blank out note/interview_note;
remove the onClick={() => setShowNote(false)} from both submit Button instances
(the ones near lines where submit Buttons are rendered) and instead move any
setShowNote(false) call to run after the server action completes (e.g., in the
promise/response handler or after await of the submit action) so the textarea
remains mounted during form submission.

In `@src/modules/requests/story-actions.ts`:
- Around line 97-117: The status update currently trusts story_uuid+request_uuid
but doesn't verify the current staff owns the request; inside
updateStoryStatusAction first load the request (e.g., prisma.request.findFirst
or findUnique) using request_uuid and the current staff identifier from the
session/context to ensure ownership, and if not owned redirect/abort; also
confirm the story belongs to that request (keep the prisma.story.findFirst check
or include request_uuid in its where) before performing the prisma.$transaction;
finally only run prisma.story.update and prisma.request.update when ownership is
validated, otherwise return the not-found/forbidden redirect.

---

Nitpick comments:
In `@src/modules/requests/RequestFulfillmentOS.tsx`:
- Around line 132-149: The duplicated inline forms in RequestFulfillmentOS.tsx
(the suggestion and invite <form> blocks wired to addCandidateSuggestionAction
and createInvitationAction) should be replaced with the shared match action
components exported from MatchActions.tsx to avoid drift; import the relevant
components from MatchActions (e.g., SuggestionForm / InviteButton or whatever
named exports exist) and pass the requestUuid and candidate.id as props (or map
to the components' expected prop names) so the payload/action wiring stays
centralized, then remove the duplicated form markup in RequestFulfillmentOS.

In `@src/modules/workspace/WorkTabs.tsx`:
- Around line 89-104: The closeTab callback uses the outer `tabs` array to
compute `remaining` for redirect, which can create a stale-closure bug; change
logic in `closeTab` (the function that calls `setTabs`, `writeTabs`, and uses
`router.push`) to compute the next active path from the updater's `prev` value
(inside the `setTabs(prev => { ... })`), store that next path to a ref (or
return it from the updater), call `writeTabs` with the new tabs, and then
perform `router.push(nextPath)` after the state update using the ref or an
effect so the redirected path always matches the persisted tabs; keep references
to `closeTab`, `setTabs`, `writeTabs`, `pathname`, `router`, and `tabs` when
locating the 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: b44e9a54-dccb-46a6-9b6f-15e7fd91ae82

📥 Commits

Reviewing files that changed from the base of the PR and between a41a46a and ece9cac.

📒 Files selected for processing (24)
  • src/app/candidate/edit/page.tsx
  • src/app/candidate/invitations/[id]/page.tsx
  • src/app/candidate/page.tsx
  • src/app/candidate/payments/page.tsx
  • src/app/candidate/work-logs/[id]/page.tsx
  • src/app/styles.css
  • src/modules/candidates/CandidateEditForm.tsx
  • src/modules/candidates/CandidateProfile.tsx
  • src/modules/candidates/InvitationRespondForm.tsx
  • src/modules/candidates/WorkLogAppealForm.tsx
  • src/modules/candidates/actions.ts
  • src/modules/requests/MatchActions.tsx
  • src/modules/requests/RequestFulfillmentOS.tsx
  • src/modules/requests/StageActions.tsx
  • src/modules/requests/application-actions.ts
  • src/modules/requests/candidate-actions.ts
  • src/modules/requests/interview-actions.ts
  • src/modules/requests/invitation-actions.ts
  • src/modules/requests/story-actions.ts
  • src/modules/workspace/WorkTabs.tsx
  • src/modules/workspace/WorkspaceOS.tsx
  • src/modules/workspace/data.ts
  • src/modules/workspace/navigation.ts
  • tsconfig.json

Comment thread src/modules/candidates/actions.ts
Comment on lines +82 to +132
const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads", "candidates");

const ALLOWED_TYPES: Record<string, { mime: string[]; ext: string[]; maxSize: number }> = {
photo: {
mime: ["image/jpeg", "image/png", "image/webp", "image/gif"],
ext: [".jpg", ".jpeg", ".png", ".webp", ".gif"],
maxSize: 5 * 1024 * 1024, // 5 MB
},
cv: {
mime: ["application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
ext: [".pdf", ".doc", ".docx"],
maxSize: 10 * 1024 * 1024, // 10 MB
},
video: {
mime: ["video/mp4", "video/webm", "video/ogg", "video/quicktime"],
ext: [".mp4", ".webm", ".ogv", ".mov"],
maxSize: 50 * 1024 * 1024, // 50 MB
},
civilFront: {
mime: ["image/jpeg", "image/png", "image/webp", "image/gif"],
ext: [".jpg", ".jpeg", ".png", ".webp", ".gif"],
maxSize: 5 * 1024 * 1024,
},
civilBack: {
mime: ["image/jpeg", "image/png", "image/webp", "image/gif"],
ext: [".jpg", ".jpeg", ".png", ".webp", ".gif"],
maxSize: 5 * 1024 * 1024,
},
};

async function saveUpload(candidateId: number, field: string, file: File, typeConfig: typeof ALLOWED_TYPES[string]): Promise<string> {
const ext = path.extname(file.name).toLowerCase();
if (!typeConfig.ext.includes(ext)) {
throw new Error(`File type "${ext}" is not allowed for this document type.`);
}

if (file.size > typeConfig.maxSize) {
throw new Error(`File is too large. Maximum size is ${typeConfig.maxSize / 1024 / 1024} MB.`);
}

const dir = path.join(UPLOAD_DIR, String(candidateId));
await fs.mkdir(dir, { recursive: true });

const filename = `${field}_${crypto.randomUUID()}${ext}`;
const filepath = path.join(dir, filename);

const buffer = Buffer.from(await file.arrayBuffer());
await fs.writeFile(filepath, buffer);

return `/uploads/candidates/${candidateId}/${filename}`;
}
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 | 🏗️ Heavy lift

Do not store sensitive candidate documents under public/.

Civil ID, CV, and personal media are written to a publicly served directory and returned as direct URLs. This weakens privacy controls for PII documents. Store privately (or behind authorized download handlers/signed URLs) instead.

🤖 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 82 - 132, The code writes
sensitive candidate files to a public web folder (UPLOAD_DIR) and returns direct
URLs; change storage to a non-public location and serve files via authorized
handlers or signed URLs instead: update the UPLOAD_DIR constant to a private
path (e.g., outside the webroot), modify saveUpload (function
saveUpload(candidateId, field, file, typeConfig)) to write files to that private
directory and return an internal storage key or path (not a public /uploads
URL), and update any callers to fetch files through a new/existing protected
download endpoint that enforces auth/authorization and can generate signed URLs;
also ensure saved files have restrictive permissions and keep the same
validation checks (ext/size) in saveUpload.

Comment thread src/modules/candidates/actions.ts Outdated
Comment thread src/modules/candidates/actions.ts
Comment thread src/modules/candidates/CandidateEditForm.tsx
Comment thread src/modules/requests/RequestFulfillmentOS.tsx Outdated
Comment thread src/modules/requests/StageActions.tsx Outdated
Comment thread src/modules/requests/StageActions.tsx Outdated
Comment thread src/modules/requests/StageActions.tsx
Comment thread src/modules/requests/story-actions.ts
BAWES and others added 4 commits May 21, 2026 13:16
Implement the full request-to-shortlist data layer for STU-25:

- invitation-actions: createInvitationAction, updateInvitationStatusAction
- interview-actions: scheduleInterviewAction, updateInterviewAction
- application-actions: transitionApplicationAction with optional note
- story-actions: createStoryAction, updateStoryStatusAction
- candidate-actions: addCandidateNote/Tag/Warning, removeCandidateTag
- MatchActions: Suggest form + Invite button on matched candidate cards
- StageActions: inline status transitions on all pipeline cards
- RequestFulfillmentOS: wired all actions into 6-card pipeline with
  story creation form and conditional status action buttons

All mutations write legacy-compatible rows. All views capability-scoped
by staff assignment via requireCapability().

TypeScript: zero errors | ESLint: zero warnings | Build: compiles

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n, import aliases

- Add staff ownership verification in interview/invitation/story update actions
- Validate interview date before constructing Date object
- Remove application_uuid fallback to interviewUuid
- Rename scopedCandidateIds to staffCandidateIds for clarity

Co-Authored-By: Paperclip <noreply@paperclip.ing>
These are generated test artifacts that should not be tracked.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@BAWES BAWES force-pushed the feature/STU-25-staff-request-fulfillment branch from ece9cac to c78c143 Compare May 21, 2026 06:20
…work log appeals, payments detail, hub content, smoke tests

Includes:
- Pipeline server actions (invitation, application, interview transitions)
- CV document bundle export with scope-enforced HTML renderer
- Work log approval/rejection and appeal resolution server actions
- Candidate payment detail page with transfer info
- HubContent component extracted for reusability
- Playwright smoke test suite for login, search, requests, portals, self-service
- Test fixture discovery and validation scripts
- Workspace OS updates for request fulfillment and stage actions

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@BAWES BAWES merged commit 34e7e58 into main May 21, 2026
4 checks passed
@BAWES BAWES deleted the feature/STU-25-staff-request-fulfillment branch May 21, 2026 06:24
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