[STU-25] Staff request fulfillment pipeline#6
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (27)
WalkthroughThis 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. ChangesStaff Candidate Management System
Request Fulfillment with Row-Level Status Actions
Candidates: Edit, Export, and Worklog
Candidate Portal Navigation
Request Pipeline: Evaluations and Notes
Configuration and Infrastructure
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 19
🧹 Nitpick comments (2)
src/modules/workspace/WorkTabs.tsx (1)
89-104: 💤 Low valuePotential stale closure when computing redirect target.
remainingis computed from the closure-capturedtabs, whilesetTabsuses the functional updater'sprev. Both should be equivalent due totabsin 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 winReuse 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
📒 Files selected for processing (24)
src/app/candidate/edit/page.tsxsrc/app/candidate/invitations/[id]/page.tsxsrc/app/candidate/page.tsxsrc/app/candidate/payments/page.tsxsrc/app/candidate/work-logs/[id]/page.tsxsrc/app/styles.csssrc/modules/candidates/CandidateEditForm.tsxsrc/modules/candidates/CandidateProfile.tsxsrc/modules/candidates/InvitationRespondForm.tsxsrc/modules/candidates/WorkLogAppealForm.tsxsrc/modules/candidates/actions.tssrc/modules/requests/MatchActions.tsxsrc/modules/requests/RequestFulfillmentOS.tsxsrc/modules/requests/StageActions.tsxsrc/modules/requests/application-actions.tssrc/modules/requests/candidate-actions.tssrc/modules/requests/interview-actions.tssrc/modules/requests/invitation-actions.tssrc/modules/requests/story-actions.tssrc/modules/workspace/WorkTabs.tsxsrc/modules/workspace/WorkspaceOS.tsxsrc/modules/workspace/data.tssrc/modules/workspace/navigation.tstsconfig.json
| 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}`; | ||
| } |
There was a problem hiding this comment.
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.
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>
ece9cac to
c78c143
Compare
…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>
Summary
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 cardssrc/modules/requests/MatchActions.tsx— suggestion + invitation forms for matched candidatessrc/modules/requests/StageActions.tsx— status transition buttons for all pipeline stagessrc/modules/requests/actions.ts—addCandidateSuggestionActionsrc/modules/requests/candidate-actions.ts— notes, tags, warnings CRUDsrc/modules/requests/invitation-actions.ts— create + update invitation statussrc/modules/requests/interview-actions.ts— schedule + update interviewsrc/modules/requests/story-actions.ts— create story + update story statussrc/modules/requests/application-actions.ts— application status transitionssrc/modules/workspace/data.ts:1298—getRequestDetail()data layer with parallel queries, skill matching, pipeline metricsServer 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
npx tsc --noElimfor zero type errorsnext buildfor successful build🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests
Chores