Recruitment — domain overview
The whole recruitment domain reduces to one idea: a hiring pipeline is four independent collections (
JobPosting → JobApplicant → InterviewSchedule → JobOffer) stitched together only byObjectIdreferences and per-entity status enums. There is no single "application" aggregate and no embedded child arrays — each stage is its own document with its own status machine, and the only cross-entity automation in the codebase is the offer → employee conversion, which atomically creates anEmployee, marks the applicantHIRED, and marks the postingFILLED.
Source: BE src/modules/recruitment · Admin src/modules/hr/recruitment (routes under src/pages/hr/recruitment/*)
1. What the domain is responsible for
The recruitment domain owns the end-to-end hiring lifecycle for a company:
- Job postings — the open requisitions (
JobPosting), with a draft → open → closed/filled lifecycle. - Applicant tracking — candidates (
JobApplicant) applying against a posting, with a six-state pipeline. - Interview scheduling — interview events (
InterviewSchedule) per applicant, with completion / cancel / no-show outcomes and feedback + rating capture. - Job offers — formal offers (
JobOffer) with a draft → sent → accepted/rejected/expired lifecycle, plus a convert-to-employee action that hands the candidate over to the employee module.
It explicitly does not:
- Compute payroll, leave, attendance, or any HR engine — conversion only seeds a minimal
Employee+User(position, salary, join date, department, employment type); everything else is filled in later by the employee module. - Send any emails or reminders. The old
zerp-be/docs/modules/recruitment.mdlists email/notification "Phase 3" plans, but no notification code exists in any of the four sub-modules (no mailer, no event emits except none — see §6). Treat all notification claims as unimplemented. - Run any approval workflow. Recruitment does not use the workflow/approval engine; transitions are direct service calls guarded only by RBAC.
- Provide a public/candidate-facing job board.
createJobApplicantis an authenticated admin mutation (@ApGqlAuthorize()); there is no anonymous apply endpoint.
2. Entity map
┌──────────────────────────────────────────────┐
│ JobPosting (collection: jobpostings) │
│ status: DRAFT → OPEN → CLOSED / FILLED │
│ departmentId, positionId → master refs │
└───────────────┬──────────────────────────────┘
│ jobPostingId (required)
▼
┌──────────────────────────────────────────────┐
│ JobApplicant (collection: jobapplicants) │
│ status: APPLIED→SCREENING→INTERVIEW→ │
│ OFFER_MADE→HIRED (or REJECTED) │
│ source: JOB_POSTING|REFERRAL|EXTERNAL │
│ attachments via FileUpload (refId = _id) │
│ employeeId? (set on conversion) │
└──────┬─────────────────────────┬─────────────┘
applicantId │ │ applicantId
▼ ▼
┌────────────────────────────────┐ ┌──────────────────────────────────┐
│ InterviewSchedule │ │ JobOffer (collection: joboffers) │
│ (interviewschedules) │ │ status: DRAFT→SENT→ACCEPTED→ │
│ status: SCHEDULED→COMPLETED │ │ [convert] (REJECTED/ │
│ /CANCELLED/NO_SHOW │ │ EXPIRED off-path) │
│ interviewType: free string │ │ employeeId? + conversion meta │
│ feedback, rating, notes │ └─────────────┬────────────────────┘
│ interviewerId → Employee │ │ convertOfferToEmployee
└────────────────────────────────┘ ▼
┌──────────────────────────────────────┐
│ Employee + User (hr/employee module) │
│ created atomically; applicant→HIRED; │
│ posting→FILLED │
└──────────────────────────────────────┘
Key relationship facts (all references, never embedded):
| From | Field | To | Required | Notes |
|---|---|---|---|---|
JobPosting |
departmentId, positionId |
master items |
yes | Resolved via MasterService.findById in @ResolveField to { _id, name }. |
JobApplicant |
jobPostingId |
JobPosting |
yes | One applicant row per (email, jobPostingId) — enforced in JobApplicantService.create. |
JobApplicant |
attachments | FileUpload |
— | Resumes/docs stored via the upload module, keyed refId = applicant._id; resolved in @ResolveField attachments. |
JobApplicant |
employeeId |
Employee |
— | Set only by offer conversion. |
InterviewSchedule |
applicantId, jobPostingId |
JobApplicant, JobPosting |
yes | Both also denormalized to applicantName / jobPostingTitle via $lookup in page(). |
InterviewSchedule |
interviewerId |
Employee |
— | Resolved via EmployeeService.getNameById. |
JobOffer |
applicantId, jobPostingId |
JobApplicant, JobPosting |
yes | Resolved to ApplicantRef / JobPostingRef. |
JobOffer |
employeeId |
Employee |
— | Set on conversion; once set, offer is "converted" and locked. |
All four collections extend BaseSchema (so they carry companyId/branchId tenant scoping, ref, documentCode, audit fields, canUpdate/View/Delete/Post), use @ApSchema({ timestamps: true }), and register mongoose-delete soft delete. Full field tables are in recruitment.md.
3. The applicant pipeline state machine (the spine of the domain)
The JobApplicant.status enum is the backbone. Two sub-machines run in parallel: the applicant status, and the offer status — they are linked only at two points (makeOffer sets OFFER_MADE; conversion sets HIRED).
ApplicantStatus
createJobApplicant
│
▼
┌─────────┐
│ APPLIED │
└────┬────┘
│ (no mutation moves APPLIED→SCREENING — gap, see below)
▼
┌───────────┐
│ SCREENING │
└─────┬─────┘
moveApplicantToInterview (requires status == SCREENING)
▼
┌───────────┐
│ INTERVIEW │
└─────┬─────┘
makeOffer (no status guard)
▼
┌────────────┐
│ OFFER_MADE │
└─────┬──────┘
convertOfferToEmployee (offer ACCEPTED)
▼
┌────────┐
│ HIRED │ + employeeId + hiredAt
└────────┘
rejectApplicant(reason) ──▶ REJECTED (from ANY status, no guard; stores reason in `notes`)
Documented-vs-code gaps in the pipeline (confirmed against code, flag in recruitment.md):
APPLIED → SCREENINGhas no transition mutation. New applicants land inAPPLIED, butmoveApplicantToInterviewrequiresstatus === SCREENINGand throws otherwise (job-applicant.service.ts). The only way to reachSCREENINGis the genericupdateJobApplicant(which accepts a freestatus) or the admin detail-page status dropdown. So the "happy path" cannot be driven by the dedicated pipeline mutations alone.- The legacy
recruitment.mdclaimedmoveApplicantToInterviewgoes directly from APPLIED → INTERVIEW. That is wrong — the code guards onSCREENING.makeOffer(the applicant-side mutation that setsOFFER_MADE) has no status guard — it can fire from any status. It does not create aJobOfferdocument; it only flips the applicant status. The actual offer document is created separately viacreateJobOffer.- No admin UI currently calls
moveApplicantToInterview,makeOffer, orrejectApplicant— they exist in the context (context.tsx) and GraphQL layer but no component triggers them. Applicant status is changed in practice only through the edit form's status dropdown (updateJobApplicant, free-formApplicantStatus). The dedicated pipeline mutations are wired but dormant.
The offer status machine and the conversion side effects are detailed in recruitment.md §Job Offers.
4. End-to-end hiring flow (posting → hire)
1. HR creates posting createJobPosting → JobPosting(status=DRAFT)
2. HR opens posting openJobPosting → status=OPEN, openedAt=now
(guard: only DRAFT can open)
3. Candidate recorded createJobApplicant → JobApplicant(status=APPLIED, source)
(guard: unique email+posting)
4. (Screening) updateJobApplicant → status=SCREENING [free-form only]
5. Move to interview moveApplicantToInterview → status=INTERVIEW (guard: was SCREENING)
6. Schedule interview scheduleInterview → InterviewSchedule(status=SCHEDULED)
7. Conduct + record completeInterview → status=COMPLETED, rating, feedback, notes
(or) cancelInterview / markInterviewNoShow → CANCELLED / NO_SHOW
8. Mark applicant offered makeOffer → applicant status=OFFER_MADE [optional, dormant in UI]
9. Create the offer doc createJobOffer → JobOffer(status=DRAFT)
10. Send offer sendOffer → status=SENT, sentAt (guard: only DRAFT)
11. Candidate accepts acceptOffer → status=ACCEPTED, acceptedAt
(guard: only SENT; reject expired)
(or) rejectOffer → status=REJECTED, rejectedAt
12. Convert to employee convertOfferToEmployee → atomic txn:
├─ EmployeeService.create({ user, employee }) → new Employee + User(kind=Staff)
├─ offer.employeeId + conversionCompletedAt/By
├─ applicant.employeeId + status=HIRED + hiredAt
└─ posting.status=FILLED + closedAt=now
Unhappy paths:
- Open a non-DRAFT posting →
"Only draft postings can be opened". - Duplicate application (same email + posting) →
BadRequestException("You have already applied for this position"). moveApplicantToInterviewfrom non-SCREENING →"Only applicants in screening can move to interview".- Send a non-DRAFT offer →
"Only draft offers can be sent"; accept a non-SENT offer →"Only sent offers can be accepted"; accept an expired offer (validUntil < now) →"Job offer has expired". - Convert when offer not
ACCEPTED→OfferNotAcceptedError(400); already converted →OfferAlreadyConvertedError(409); applicant already an employee →ApplicantAlreadyEmployeeError(409); missing mandatory fields →MissingMandatoryFieldsError(400); start date in the past or > 2 years out → plainError.
5. Feature gating & permissions
Feature flag (subscription): every resolver in all four sub-modules is decorated @UseGuards(GqlFeatureGuard) + @RequireFeature('RECRUITMENT_MODULE'). The company's subscription must include the RECRUITMENT_MODULE feature (seeded in subscription/subscription.seed.ts as { key: "RECRUITMENT_MODULE", category: CORE }). Without it, all recruitment operations are blocked. See subscription / feature gating.
Note: recruitment is gated by
RECRUITMENT_MODULE, notHR_MODULE— it is a separately licensed feature even though it lives under the HR admin nav.
RBAC permission modules (seeded by RecruitmentSeedService from constants.ts, keys from ApModules):
| Permission module | ApModules key |
Actions |
|---|---|---|
| Job Postings | JOB_POSTINGS = job-postings |
create, read, update, delete, open, close, mark_filled |
| Job Applicants | JOB_APPLICANTS = job-applicants |
create, read, move_to_interview, make_offer, reject |
| Interview Schedules | INTERVIEW_SCHEDULES = interview-schedules |
create, read, complete, cancel, mark_no_show |
| Job Offers | JOB_OFFERS = job-offers |
create, read, update, send, accept, reject, convert_to_employee |
Enforcement asymmetry (flag): only the Job Offers resolver actually attaches per-action permission checks (
@ApGqlAuthorize({ permission: { subject: ApModules.JOB_OFFERS, action: ... } })). Job postings, applicants, and interview-schedule resolvers use bare@ApGqlAuthorize()(JWT only) — their seeded action keys are not enforced server-side, only used by the admin UI (USER_ACCESS.*inconstants/UserAccess.ts) to show/hide buttons. See permissions-access.
6. Cross-cutting behaviors
- Audit trail: every mutation across all four sub-modules carries
@AuditMeta({ module: 'recruitment', collection: 'recruitment_<entity>', snapshots: [...] })—CREATEon create,UPDATEon update,STATUS_CHANGEon every lifecycle transition,DELETEon delete. See audit-trail. - Events: none emitted or consumed. (Contrast with employee, which emits
employee.created— but note conversion callsEmployeeService.create, so that downstream event does fire as a side effect of conversion.) - Soft delete: all four collections use
mongoose-delete(deletedAt: true); aggregations filterdeleted: { $ne: true }. Records are preserved for hiring history. - Transactions: only offer conversion runs in a Mongo transaction (
transactionManager.run). All other transitions are singlerepo.updatecalls (not transactional). The base service exposeswithRetryTransactionbut recruitment status transitions do not use it. - Multi-tenancy:
companyId/branchIdfromBaseSchema; tenant scoping is applied by the base repositorybuildQuery. Conversion stampsconversionCompletedByfromApContextService.userId.
7. Admin surface (routes)
All under zerp-admin/src/pages/hr/recruitment/, each thin page rendering a module screen wrapped in RecruitmentContextProvider:
| Route | Screen | Purpose |
|---|---|---|
/hr/recruitment/postings · /postings/[id] |
postings-page.tsx · postings-detail.tsx |
List/create/edit postings; detail shows applicants + open/close/mark-filled actions. |
/hr/recruitment/applicants · /applicants/[id] |
applicants-page.tsx · applicants-detail.tsx |
List/create applicants; detail edits fields + status, manages resume attachments. |
/hr/recruitment/interviews · /interviews/[id] |
interviews-page.tsx · interviews-detail.tsx |
List/schedule interviews; detail records completion (feedback + rating). |
/hr/recruitment/offers · /offers/[id] |
offers-page.tsx · offers-detail.tsx |
List/create/edit offers; detail drives send/accept/reject + convert-to-employee. |
The whole domain shares one React context (context.tsx) and one GraphQL hook (gql/query.ts). Master-data selects (ApMasterSelectInput) feed position, currency, and interview_type — these are master items, not the BE enums. Full UI detail in recruitment.md §Admin UI.
Admin ↔︎ BE enum drift (flag): the admin
model.tsdefinesInterviewType = PHONE | VIDEO | IN_PERSONandOfferStatuswithWITHDRAWN, but the BE definesInterviewType = PHONE_SCREEN | FIRST_ROUND | SECOND_ROUND | FINAL | OFFERandOfferStatuswithEXPIRED(noWITHDRAWN). The adminInterviewTypeenum is unused (interview type is sourced from master data as a free string); the adminOfferStatus.WITHDRAWNis a value the backend never produces.
8. Replication checklist
- Four
BaseSchemacollections (JobPosting,JobApplicant,InterviewSchedule,JobOffer), each with its own status enum + soft delete, linked only byObjectIdrefs. - Per-entity status transition methods on each service with explicit guards (open requires DRAFT, accept requires SENT, etc.) — single
repo.updateeach. JobApplicantService.creatededup on(email, jobPostingId).@ResolveFielddenormalizers for department/position (master), applicant/posting refs, interviewer name, and applicant attachments (upload module byrefId).- The conversion path: validator (
ConvertApplicantToEmployeeValidator) → mapper (JobOfferMapper) → transactionalEmployeeService.create+ three status writes (offer/applicant/posting). - Feature gate
RECRUITMENT_MODULE+ RBAC seed of four permission modules with their action keys. @AuditMetaon every mutation.
One-line mental model
Four loosely-coupled status machines joined by ObjectId refs; the only automated cross-entity action is the transactional offer→employee conversion that fans out to applicant=HIRED and posting=FILLED.