Home
Projects
Blog
Let's Talk
Back to Projects
Client work · Under NDA

VRMS

Vendor Recruitment Management System

Built for the US staffing market: staffing-agency candidates go from submission through interviews, offers and joining, then into audited timesheets and pay statements.

Built with one other developer. I wrote most of it, about 170 of the 208 commits, across the NestJS API and the Next.js app.

roles, plus database-backed custom roles
5
API endpoints
166
stages in the hiring pipeline
11
API test cases
373
Operations dashboard with KPI cards, a hiring pipeline funnel and a recent activity feed

The problem

Companies that hire through outside staffing agencies were coordinating compliance documents, candidate ownership, interview scheduling and post-placement hours over email and spreadsheets. Agencies compete with each other, so none may see another's candidates, recruiters must not see each other's requisitions, and every stage change and every change to money has to be traceable. The market is US staffing, so onboarding means EINs, W-9s, insurance certificates and hourly USD rates.

What I built

  • Vendor onboarding

    Prospect approval, sign-up on a company email domain, KYC with US compliance documents, per-document re-upload and agreement signing.

  • Requisitions

    Versioned jobs that can be published, paused and closed, with vendor visibility windows, restricted vendor lists and vendor Q&A.

  • Candidates and submissions

    Vendor benches with duplicate checks, and single or bulk submission against a job.

  • Pipeline

    An admin-configurable stage graph, a Kanban board, a timeline and bulk moves, with one service writing every stage change.

  • Interviews

    Turn-based slot negotiation between recruiter and vendor, recruiter approval with a meeting link, and interviewer feedback by signed link.

  • Offers and verification

    Offers with an approval threshold, vendor accept, decline or hold, and background verification progress.

  • Placement

    Joining creates employment, locks the hourly rate and opens a narrow candidate portal.

  • Timesheets and pay

    Monthly timesheets routed to company-assigned verifiers; approval generates a pay statement.

  • Notifications and search

    In-app notifications over Socket.IO, email fan-out, Postgres full-text search, hourly leaderboards and an audit log.

  • Access

    Five roles, custom roles, status-scoped permissions for pending vendors, TOTP and rotating refresh tokens.

System architecture

Scroll sideways to see the whole diagram.Open full size
System architecture: browsers for five roles and external interviewers reach a Traefik proxy on a single Coolify VPS; the proxy serves the Next.js web container and forwards /api/v1 and /ws to a NestJS API container with REST guards, a Socket.IO gateway and an in-process BullMQ worker; Redis holds the queue; the API uses PostgreSQL through Prisma, Cloudinary for files and SMTP for email; GitHub Actions runs CI and Coolify builds images from main.

One deployment serves one organisation on a single VPS managed by Coolify, which provides the Traefik proxy and TLS. The browser loads the Next.js app and calls the NestJS REST API under /api/v1 with a 15-minute access token and a rotating httpOnly refresh cookie, while a Socket.IO gateway in the same container pushes notifications into one room per user, with polling as the fallback. The API reaches PostgreSQL through Prisma and runs migrations on container start; Redis backs a BullMQ queue whose worker runs in-process and writes hourly leaderboard snapshots. Files live in Cloudinary as authenticated resources and browsers only ever get signed, expiring API proxy URLs. Separation between agencies, recruiters and verifier companies happens on every request in global guards and service-layer scope filters.

Data model

Scroll sideways to see the whole diagram.Open full size
Entity relationship diagram of the vendor recruitment platform: users, custom roles, refresh tokens, audit log entries and company assignments; vendor prospects, vendors, KYC submissions and candidates; pipeline stage config, jobs, submissions, pipeline events, interviews and offers; employing companies, employment, timesheets, pay statements and background verification.

The unit of the pipeline is the Submission, not the Candidate, so one candidate can be in many jobs through many vendors. Submission.stage has a single writer and every change appends a PipelineEvent in the same transaction; the stage graph itself lives in a config table matched by key, not a foreign key. Joining turns a submission into Employment with the pay rate locked, timesheets are keyed by employment and month, and each approved timesheet produces a PayStatement that stores the rate it used, so a later rate change never rewrites a past month. Competing agencies, recruiters and verifier companies are separated by vendor ownership, job ownership and company assignments rather than a tenant column.

Key problems and how I solved them

01Making "Joined" mean something

Problem
A placement has to bind the candidate to an employing company, lock their pay rate, start timesheets and give them a login, but the board let anyone drop a card on Joined.
Approach
The pipeline refuses the joining stage unless the move comes from the join confirmation, and bulk moves refuse it outright. The only path is join(), which requires an accepted offer, an approved employing company and background verification at 100%, then in one transaction ends any prior employment, creates Employment with the locked rate, writes the rate history, provisions the candidate's login with an invite, marks the offer joined and audits it.
Why it was hard
The join action existed in the API but no screen called it, so the bug looked like correct placements, and a second bug closed the submission on join and dropped the placement from vendor leaderboards.
Result
The invariant is enforced on the server, dropping a card on Joined opens a confirmation that runs the real join, and placements that had slipped through without employment were found and fixed.

02Money that never rewrites history

Problem
A candidate's pay rate was a free, unaudited number, and approved hours had no dollar value.
Approach
The rate is locked on Employment at joining. Approving a timesheet generates the PayStatement in the same transaction and stores rateApplied. Only a super admin or the company's verifier can change a rate, and each change writes a rate history row and an audit entry.
Why it was hard
Where the rate lives, what happens to a reopened timesheet, and Float versus Decimal all had to be decided, and were written up as a decision log before any code.
Result
Verified end to end against the real database, including that a later rate change leaves an earlier month's statement untouched.

03A pipeline admins can reshape

Problem
Clients reorder and rename hiring stages, and a flow hardcoded in the app silently goes stale.
Approach
A stage config table is the live transition graph, seeded by a migration so a fresh deploy works. The pipeline service is the only writer of the stage, accepts forward moves by order or by an explicit edge, and writes an event and an audit row per move. The frontend reads stages through a hook instead of hardcoded arrays.
Why it was hard
Three kinds of drift showed up in the UI (a stale next-stage helper, duplicated columns after a reorder, a scheduling dialog rejecting legitimate skips), and saving the stages ran ten sequential upserts in one transaction against a remote database, which timed out and never persisted.
Result
The upserts run concurrently inside one transaction, and open interview negotiations are cancelled automatically when a candidate skips past their round.

04Scheduling interviews between two organisations

Problem
Recruiter and vendor negotiate slots asynchronously; neither side should confirm its own proposal, and nothing counts as scheduled until there is a meeting link.
Approach
proposedBy names the side that owns the single active slot list, and selectSlot() only accepts from that list and rejects the proposer. Acceptance moves the interview to pending approval, and only the recruiter's approve() with a link or location confirms it and sends the email. Renegotiation goes through reschedule().
Why it was hard
The first version treated the original and counter slots as one valid set and never checked who had proposed them, and recruiters lacked the permission to accept a counter at all.
Result
The negotiation invariants are enforced in the service and documented for the next developer.

05Competing agencies on one platform

Problem
Vendors compete with each other, so one agency seeing another's bench would end the product.
Approach
Scope rules are written once and applied last, so they always win over filters in the request, and an unknown role gets a filter that matches nothing, so a mistake fails closed to an empty result. Another agency's candidate ID answers "not found", exactly like a missing one. Pending vendors get a status-scoped permission set, and a vendor's job board starts at their KYC approval date.
Why it was hard
Isolation lives in the application rather than the database, so it has to hold on every query path, including filters the client sends.
Result
Regression tests on the candidate and submission services pin the scoping.

06Sessions that survive multiple tabs

Problem
Rotating refresh tokens with reuse detection revoke the whole session on a replay, which is right for a stolen token but also fired when two tabs refreshed at once.
Approach
The API revokes the token family on reuse. The client wraps rotation in a browser-wide Web Locks mutex plus a single-flight promise per tab, so a waiting tab uses the new cookie instead of replaying the old one.
Why it was hard
The bug only appeared across tabs, and the server behaving correctly was what caused it.
Result
Users stay signed in across tabs while token-theft detection stays on.

A request, end to end

A vendor submits a new candidate to a job

  1. 1In the submit dialog the vendor uploads a resume through a signed upload URL proxied by the API, and the dialog runs a duplicate check on email and phone.
  2. 2The candidate is created on the vendor's bench, and a Postgres trigger fills its search vector.
  3. 3The client posts to /api/v1/submissions; the axios client attaches the bearer token and refreshes it on a 401.
  4. 4Traefik routes to the API container, where helmet, the global validation pipe and the /api/v1 prefix apply.
  5. 5Global guards run in order: throttler, JWT, then the permission guard checks candidate:submit for an active vendor, so a vendor still in KYC is refused here.
  6. 6The service checks the vendor is active, the job is published, the candidate belongs to this vendor (otherwise "not found") and there is no existing submission for the same candidate, job and vendor.
  7. 7One transaction inserts the Submission at SUBMITTED, a PipelineEvent and an audit entry.
  8. 8The job owner is emailed through a mail service that never throws.
  9. 9The API returns 201 and the client invalidates its queries, so the recruiter's board and the vendor's submissions refetch.
  10. 10The recruiter sees the card in the first column of the board, whose columns come from the stage config, and moves it forward.

Stack

Frontend

Next.js 16React 19TypeScript

Backend

NestJSSocket.IOPrisma 6

Data

PostgreSQLRedisBullMQ

Infra

DockerTurborepo

What's next

  • Integration tests against a real Postgres alongside the unit suite.
  • Load testing and single sign-on.
  • Scheduled report delivery.
  • Finer-grained sub-roles stored in the database.