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 by ObjectId references 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 an Employee, marks the applicant HIRED, and marks the posting FILLED.

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.md lists 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. createJobApplicant is 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):

  1. APPLIED → SCREENING has no transition mutation. New applicants land in APPLIED, but moveApplicantToInterview requires status === SCREENING and throws otherwise (job-applicant.service.ts). The only way to reach SCREENING is the generic updateJobApplicant (which accepts a free status) or the admin detail-page status dropdown. So the "happy path" cannot be driven by the dedicated pipeline mutations alone.
  2. The legacy recruitment.md claimed moveApplicantToInterview goes directly from APPLIED → INTERVIEW. That is wrong — the code guards on SCREENING.
  3. makeOffer (the applicant-side mutation that sets OFFER_MADE) has no status guard — it can fire from any status. It does not create a JobOffer document; it only flips the applicant status. The actual offer document is created separately via createJobOffer.
  4. No admin UI currently calls moveApplicantToInterview, makeOffer, or rejectApplicant — 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-form ApplicantStatus). 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").
  • moveApplicantToInterview from 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 ACCEPTEDOfferNotAcceptedError (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 → plain Error.

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, not HR_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.* in constants/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: [...] })CREATE on create, UPDATE on update, STATUS_CHANGE on every lifecycle transition, DELETE on delete. See audit-trail.
  • Events: none emitted or consumed. (Contrast with employee, which emits employee.created — but note conversion calls EmployeeService.create, so that downstream event does fire as a side effect of conversion.)
  • Soft delete: all four collections use mongoose-delete (deletedAt: true); aggregations filter deleted: { $ne: true }. Records are preserved for hiring history.
  • Transactions: only offer conversion runs in a Mongo transaction (transactionManager.run). All other transitions are single repo.update calls (not transactional). The base service exposes withRetryTransaction but recruitment status transitions do not use it.
  • Multi-tenancy: companyId / branchId from BaseSchema; tenant scoping is applied by the base repository buildQuery. Conversion stamps conversionCompletedBy from ApContextService.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.ts defines InterviewType = PHONE | VIDEO | IN_PERSON and OfferStatus with WITHDRAWN, but the BE defines InterviewType = PHONE_SCREEN | FIRST_ROUND | SECOND_ROUND | FINAL | OFFER and OfferStatus with EXPIRED (no WITHDRAWN). The admin InterviewType enum is unused (interview type is sourced from master data as a free string); the admin OfferStatus.WITHDRAWN is a value the backend never produces.


8. Replication checklist

  1. Four BaseSchema collections (JobPosting, JobApplicant, InterviewSchedule, JobOffer), each with its own status enum + soft delete, linked only by ObjectId refs.
  2. Per-entity status transition methods on each service with explicit guards (open requires DRAFT, accept requires SENT, etc.) — single repo.update each.
  3. JobApplicantService.create dedup on (email, jobPostingId).
  4. @ResolveField denormalizers for department/position (master), applicant/posting refs, interviewer name, and applicant attachments (upload module by refId).
  5. The conversion path: validator (ConvertApplicantToEmployeeValidator) → mapper (JobOfferMapper) → transactional EmployeeService.create + three status writes (offer/applicant/posting).
  6. Feature gate RECRUITMENT_MODULE + RBAC seed of four permission modules with their action keys.
  7. @AuditMeta on 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.