Recruitment — job postings, applicants, interviews & offers

The whole recruitment module reduces to one idea: four independent, soft-deletable BaseSchema collections, each with its own status enum, linked only by ObjectId references — and one transactional bridge (convertOfferToEmployee) that turns an accepted offer into a live Employee. No embedded children, no shared aggregate, no approval engine, no notifications.

Source: BE src/modules/recruitment · Admin src/modules/hr/recruitment (routes src/pages/hr/recruitment/*)

See _overview.md for the domain entity map, the applicant pipeline state machine, feature gating, and the end-to-end flow. This doc is the per-entity detail.


1. Purpose & scope

This module manages the full hiring lifecycle across four sub-modules:

  • job-posting — open requisitions and their draft/open/closed/filled lifecycle.
  • job-applicant — candidates applying against a posting; six-state pipeline; resume/document attachments.
  • interview-schedule — interview events per applicant; outcomes + feedback/rating.
  • job-offer — formal offers; draft/sent/accepted/rejected/expired lifecycle; convert-to-employee.

It does not: compute any HR engine (payroll/leave/attendance), send emails/reminders (no notification code exists), run any approval workflow, or expose a public candidate-facing apply endpoint (all mutations are JWT-guarded admin operations).

Module wiring (recruitment.module.ts): aggregates JobPostingModule, JobApplicantModule, InterviewScheduleModule, JobOfferModule, plus RecruitmentSeedService (seeds RBAC permission modules at boot). Each sub-module registers its own Mongoose model and exports its service.


2. Data model

All four schemas extend BaseSchema (core/database/database.scheme.ts) → carry _id, companyId, branchId (multi-tenant), ref, documentCode, documentDate, createdAt/By, updatedAt/By, deletedAt/By, deleted, canUpdate/View/Delete/Post, client. All are @ApSchema({ timestamps: true }) and register mongoose-delete ({ deletedAt: true }). *Id props use set: (val) => BaseSchema.toObjectId(val) to coerce 24-char hex strings to ObjectId.

Mongo collection names default from the class name lowercased+pluralized: jobpostings, jobapplicants, interviewschedules, joboffers (confirmed by the $lookup from: values in interview-schedule.repository.ts: jobapplicants, jobpostings). Note: the @AuditMeta collection labels use a different naming (recruitment_job_postings, etc.) — those are audit-trail labels, not Mongo collection names.


2.1 JobPostingjobpostings

job-posting/job-posting.schema.ts. The open requisition.

field type required description
departmentId ObjectId → master yes Department (resolved to { _id, name } via MasterService). Indexed.
positionId ObjectId → master yes Position/role (resolved to { _id, name }).
title string yes Job title (regex-searchable).
description string yes Public description (regex-searchable).
requirements string yes Requirements text (regex-searchable).
salaryRange { min: number; max: number } Embedded sub-doc, default { min: 0, max: 0 }. Public salary range.
status JobPostingStatus Default DRAFT. Indexed.
openedAt number (unix ms) Set by openPosting.
closedAt number (unix ms) Set by closePosting and by conversion (FILLED).
export enum JobPostingStatus {
  DRAFT  = 'DRAFT',   // default — editable, not visible to applicants
  OPEN   = 'OPEN',    // accepting applications
  CLOSED = 'CLOSED',  // no longer hiring
  FILLED = 'FILLED',  // position filled (set manually or by conversion)
}

Indexes: { status }, { ref } unique, { departmentId }, { createdAt: -1 }.


2.2 JobApplicantjobapplicants

job-applicant/job-applicant.schema.ts. A candidate applying to one posting.

field type required description
jobPostingId ObjectId → JobPosting yes The posting applied to.
email string yes Candidate email. Part of the dedup key.
fullName string yes Candidate name (regex-searchable).
phoneNumber string yes Candidate phone (regex-searchable).
resumeUrl string External resume link (separate from file attachments).
coverLetter string Cover letter text.
appliedAt number (unix ms) Default Date.now.
status ApplicantStatus Default APPLIED.
source ApplicationSource Default JOB_POSTING. Recruitment-channel tracking.
city string Candidate city.
notes string Internal notes; also where rejectApplicant stores the rejection reason.
employeeId ObjectId → Employee Set only by offer conversion. Sparse-indexed.
hiredAt number (unix ms) Set by conversion (when status → HIRED).
attachments [FileUpload] (virtual) Resolved in resolver, not stored — see §2.5.
export enum ApplicantStatus {
  APPLIED    = 'APPLIED',     // default — new application
  SCREENING  = 'SCREENING',   // under review (only reachable via free-form update; see §4)
  INTERVIEW  = 'INTERVIEW',   // moved to interview stage
  OFFER_MADE = 'OFFER_MADE',  // offer extended (applicant-side flag)
  HIRED      = 'HIRED',       // converted to employee
  REJECTED   = 'REJECTED',    // rejected at any stage
}

export enum ApplicationSource {
  JOB_POSTING = 'JOB_POSTING', // default
  REFERRAL    = 'REFERRAL',
  EXTERNAL    = 'EXTERNAL',
}

Indexes: { jobPostingId, status }, { email }, { ref } unique sparse, { appliedAt: -1 }, { employeeId } sparse.


2.3 InterviewScheduleinterviewschedules

interview-schedule/interview-schedule.schema.ts. One interview event for an applicant.

field type required description
applicantId ObjectId → JobApplicant yes Indexed. Resolved to ApplicantRef + denormalized applicantName.
jobPostingId ObjectId → JobPosting yes Indexed. Denormalized jobPostingTitle.
interviewType string yes Free string, not an enum on the schema. Admin fills from master data interview_type.
scheduledAt number (unix ms) yes Interview date/time. Indexed.
interviewerId ObjectId → Employee Resolved to EmployeeRef { _id, name } via EmployeeService.getNameById.
status InterviewStatus Default SCHEDULED.
feedback string Free-text feedback (set on complete).
rating number Numeric rating (set on complete; GraphQL Float).
notes string Additional notes (regex-searchable).
// schema enum (BE):
export enum InterviewType {
  PHONE_SCREEN = 'PHONE_SCREEN',
  FIRST_ROUND  = 'FIRST_ROUND',
  SECOND_ROUND = 'SECOND_ROUND',
  FINAL        = 'FINAL',
  OFFER        = 'OFFER',
}                                // ← declared but NOT used: interviewType is stored as a free string,
                                 //   and NOT registered in GraphQL. Admin sources it from master data.

export enum InterviewStatus {
  SCHEDULED = 'SCHEDULED',   // default
  COMPLETED = 'COMPLETED',
  CANCELLED = 'CANCELLED',
  NO_SHOW   = 'NO_SHOW',
}

Gotcha: the legacy doc claimed feedback is a JSON object ({"technicalScore": 8}). In code feedback is a plain string (GraphQL String), and rating is a single number. There is no structured-feedback JSON.

Indexes: { applicantId }, { jobPostingId }, { scheduledAt }, { ref } unique.


2.4 JobOfferjoboffers

job-offer/job-offer.schema.ts. A formal offer to an applicant.

field type required description
applicantId ObjectId → JobApplicant yes Indexed. Resolved to ApplicantRef.
jobPostingId ObjectId → JobPosting yes Indexed. Resolved to JobPostingRef.
position string yes Offered position (free string; admin from master position).
baseSalary number yes Offered salary → becomes Employee.basicSalary on conversion.
currency string yes Currency code (free string; admin from master currency).
benefits string Benefits description (free text).
startDate number (unix ms) yes Offered start date → becomes Employee.joinDate. Validated (not past, ≤ 2yr future).
status OfferStatus Default DRAFT. Indexed.
sentAt number Set by sendOffer.
acceptedAt number Set by acceptOffer.
rejectedAt number Set by rejectOffer.
validUntil number Expiry; accept fails if validUntil < now.
employeeId ObjectId → Employee Set by conversion; presence = "already converted" lock. Sparse-indexed.
conversionCompletedAt number Set by conversion.
conversionCompletedBy string (userId) From ApContextService.userId.
export enum OfferStatus {
  DRAFT    = 'DRAFT',     // default — editable
  SENT     = 'SENT',      // delivered to candidate
  ACCEPTED = 'ACCEPTED',  // candidate accepted → eligible for conversion
  REJECTED = 'REJECTED',  // candidate declined
  EXPIRED  = 'EXPIRED',   // past validUntil — NOTE: no mutation ever sets EXPIRED (see §9)
}

Indexes: { applicantId }, { jobPostingId }, { status }, { ref } unique, { employeeId } sparse.


2.5 Resolve-fields (computed / virtual)

Entity Field Returns Source
JobPosting position MasterRef { _id, name } MasterService.findById(positionId)
JobPosting department MasterRef MasterService.findById(departmentId)
JobApplicant attachments [FileUpload] FileUploadService.findByRefId(applicant._id) — resumes/docs from upload module
InterviewSchedule interviewer EmployeeRef { _id, name } EmployeeService.getNameById(interviewerId)
InterviewSchedule applicant ApplicantRef { _id, fullName, email, phoneNumber } JobApplicantService.findById
InterviewSchedule applicantName, jobPostingTitle string Denormalized in page() via $lookup
JobOffer applicant ApplicantRef JobApplicantService.findById
JobOffer jobPosting JobPostingRef { _id, title } JobPostingService.findById

3. API surface

All GraphQL. Every resolver class is decorated @UseGuards(GqlFeatureGuard) + @RequireFeature('RECRUITMENT_MODULE') + @ApGqlAuthorize(). There are no REST controllers in this module. Pagination is offset-based: query inputs carry skip + take; results are { data: [...], totalRecords }.

3.1 Job Posting (job-posting.resolver.ts)

Operation Type Input Returns Permission
jobPostings Query JobPostingQuery JobPostingPageResult JWT only
jobPosting Query id: String! JobPosting JWT only
createJobPosting Mutation CreateJobPostingInput JobPosting JWT; audit CREATE. Stamps createdBy = user.id.
updateJobPosting Mutation id, UpdateJobPostingInput JobPosting JWT; audit UPDATE
deleteJobPosting Mutation id Boolean JWT; audit DELETE; soft-delete
openJobPosting Mutation postingId JobPosting JWT; audit STATUS_CHANGE
closeJobPosting Mutation postingId JobPosting JWT; audit STATUS_CHANGE
markJobPostingFilled Mutation postingId JobPosting JWT; audit STATUS_CHANGE

CreateJobPostingInput: departmentId, positionId, title, description, requirements, salaryRange: SalaryRangeInput{min,max}. UpdateJobPostingInput = PartialType(Create). JobPostingQuery = PartialType(Create) + keyword? + status? + skip + take.

3.2 Job Applicant (job-applicant.resolver.ts)

Operation Type Input Returns Permission
jobApplicants Query JobApplicantQuery JobApplicantPageResult JWT only
jobApplicant Query id JobApplicant JWT only
createJobApplicant Mutation CreateJobApplicantInput JobApplicant JWT; audit CREATE; dedup guard
updateJobApplicant Mutation id, UpdateJobApplicantInput JobApplicant JWT; audit UPDATE
moveApplicantToInterview Mutation applicantId JobApplicant JWT; audit STATUS_CHANGE; guard status==SCREENING
makeOffer Mutation applicantId JobApplicant JWT; audit STATUS_CHANGE; sets OFFER_MADE (no guard)
rejectApplicant Mutation applicantId, reason: String! JobApplicant JWT; audit STATUS_CHANGE; sets REJECTED, stores reason in notes

Naming corrections vs the legacy doc: the mutation is makeOffer (not makeJobOffer); the create mutation is createJobApplicant taking only input (no storeId arg). CreateJobApplicantInput: jobPostingId, email, fullName, phoneNumber, city?, resumeUrl?, coverLetter?, notes?, source?. UpdateJobApplicantInput adds editable status?. JobApplicantQuery: jobPostingId?, keyword?, status?, skip, take.

3.3 Interview Schedule (interview-schedule.resolver.ts)

Operation Type Input Returns Permission
interviewSchedules Query InterviewQuery InterviewSchedulePageResult JWT only
interviewSchedule Query id InterviewSchedule JWT only
scheduleInterview Mutation ScheduleInterviewInput InterviewSchedule JWT; audit CREATE; stamps createdBy
completeInterview Mutation id, rating: Float!, feedback: String!, notes? InterviewSchedule JWT; audit STATUS_CHANGE → COMPLETED
cancelInterview Mutation id InterviewSchedule JWT; audit STATUS_CHANGE → CANCELLED
markInterviewNoShow Mutation id InterviewSchedule JWT; audit STATUS_CHANGE → NO_SHOW

ScheduleInterviewInput: applicantId, jobPostingId, interviewType, scheduledAt, interviewerId?. InterviewQuery: applicantId?, jobPostingId?, status?, skip, take.

Arg correction vs legacy doc: completeInterview takes id (not interviewId); feedback is a required String arg (not JSON); there is no scheduleInterview(storeId) arg.

3.4 Job Offer (job-offer.resolver.ts) — the only resolver with per-action RBAC

Operation Type Input Returns Permission (subject: JOB_OFFERS)
jobOffers Query JobOfferQuery JobOfferPageResult read
jobOffer Query id JobOffer read
createJobOffer Mutation CreateJobOfferInput JobOffer create; audit CREATE; stamps createdBy
updateJobOffer Mutation id, UpdateJobOfferInput JobOffer update; audit UPDATE
sendOffer Mutation id JobOffer send; STATUS_CHANGE; guard status==DRAFT
acceptOffer Mutation id JobOffer accept; STATUS_CHANGE; guard status==SENT + not expired
rejectOffer Mutation id JobOffer reject; STATUS_CHANGE → REJECTED
convertOfferToEmployee Mutation offerId JobOffer convert_to_employee; STATUS_CHANGE; full validation + transaction

CreateJobOfferInput: applicantId, jobPostingId, position, baseSalary, currency, benefits?, startDate, validUntil?. UpdateJobOfferInput = PartialType(Create). JobOfferQuery: applicantId?, jobPostingId?, status?, skip, take.

Dead DTOs (flag): recruitment.dto.ts declares ConvertOfferToEmployeeInput and OfferConversionResult { success, message, employeeId?, updatedOffer? }, but no resolver uses themconvertOfferToEmployee takes a plain offerId: String! arg and returns JobOffer!. Verified: only references are the class declarations themselves.


4. Business rules & state machines

4.1 Job posting transitions (job-posting.service.ts)

openPosting(id):   load → require status==DRAFT  (else "Only draft postings can be opened")
                   → update { status: OPEN, openedAt: now }
closePosting(id):  load (only "not found" check) → update { status: CLOSED, closedAt: now }
markFilled(id):    load (only "not found" check) → update { status: FILLED }

Only open enforces a source-status guard. close and markFilled accept any non-deleted posting (e.g. you can CLOSE a DRAFT, or FILL an OPEN). No transition back from CLOSED/FILLED → OPEN exists.

4.2 Applicant transitions (job-applicant.service.ts)

  • create(applicant): findByEmail(email, jobPostingId); if a row exists → BadRequestException("You have already applied for this position"); else super.create. Dedup key = (email, jobPostingId).
  • moveToInterview(id): load; require status == SCREENING (else "Only applicants in screening can move to interview") → { status: INTERVIEW }.
  • makeOffer(id): load (only "not found") → { status: OFFER_MADE }. No status guard; does not create a JobOffer doc.
  • rejectApplicant(id, reason): → { status: REJECTED, notes: reason }. No guard; allowed from any status.

The APPLIED → SCREENING gap (confirmed): no dedicated mutation sets SCREENING. The only ways: (a) updateJobApplicant with a free-form status value, or (b) the admin detail-page status dropdown (which calls updateJobApplicant). Since moveToInterview requires SCREENING, the dedicated pipeline cannot run end-to-end without that free-form step. The legacy doc's "APPLIED → INTERVIEW directly" claim is wrong.

Full pipeline diagram in _overview.md §3.

4.3 Interview transitions (interview-schedule.service.ts)

completeInterview(id, rating, feedback, notes?) → { status: COMPLETED, rating, feedback, notes }
cancelInterview(id)                             → repo.updateStatus(id, CANCELLED)
markNoShow(id)                                  → repo.updateStatus(id, NO_SHOW)

No source-status guards on any interview transition.

4.4 Offer transitions (job-offer.service.ts)

sendOffer(id):   require status==DRAFT  (else "Only draft offers can be sent")
                 → { status: SENT, sentAt: now }
acceptOffer(id): require status==SENT   (else "Only sent offers can be accepted")
                 if validUntil && validUntil < now → "Job offer has expired"
                 → { status: ACCEPTED, acceptedAt: now }
rejectOffer(id): (only "not found") → { status: REJECTED, rejectedAt: now }

4.5 Offer → Employee conversion (convertOfferToEmployee) — the core algorithm

This is the only multi-entity, transactional operation. Sequence (job-offer.service.ts):

1. Load offer, applicant (offer.applicantId), jobPosting (offer.jobPostingId) — each "not found" guarded.
2. Validate (ConvertApplicantToEmployeeValidator):
     validateOfferForConversion:
        - offer.status must == 'ACCEPTED'  → OfferNotAcceptedError (400)
        - offer.employeeId must be empty   → OfferAlreadyConvertedError (409)
        - if validUntil < now              → OfferExpiredError (400)
     validateApplicantForConversion:
        - applicant.employeeId must be empty → ApplicantAlreadyEmployeeError (409)
     validateMandatoryFields → MissingMandatoryFieldsError (400) if any of:
        offer.position, offer.baseSalary (0 allowed), offer.currency, offer.startDate,
        applicant.email, applicant.fullName, applicant.phoneNumber, jobPosting.departmentId
     validateStartDateLogic (plain Error):
        - startDate >= now   ("Start date cannot be in the past")
        - startDate <= now + 2 years ("...more than 2 years in the future")
3. Map (JobOfferMapper.mapOfferToEmployeeData):
     user     = { email, name=applicant.fullName, phoneNumber, kind:'Staff', roles:['Staff'], active:true }
     employee = { position=offer.position, basicSalary=offer.baseSalary, joinDate=offer.startDate,
                  departmentId=jobPosting.departmentId, employmentType:'FULL_TIME' (hardcoded default) }
4. transactionManager.run(async):
     a. EmployeeService.create({ ...employee, user })  → new Employee (+ User, kind=Staff)
        if no _id → "Failed to create employee record"
     b. offer.update    { employeeId, conversionCompletedAt: now, conversionCompletedBy: ctx.userId }
     c. applicant.update{ employeeId, status: HIRED, hiredAt: now }
     d. posting.update  { status: 'FILLED', closedAt: now }
     returns the updated offer
   (any throw → "Conversion transaction failed: <msg>")

Side effects of conversion: creates an Employee + User; because EmployeeService.create emits employee.created, the attendance device back-link side effect fires transitively. Three status writes (offer/applicant/posting) commit atomically with the employee create.

Transactionality: conversion is the only recruitment operation wrapped in a Mongo transaction. All other transitions are single non-transactional repo.update calls.

4.6 Side effects summary

Operation Writes Side effects
any mutation the entity @AuditMeta snapshot (CREATE/UPDATE/STATUS_CHANGE/DELETE)
rejectApplicant applicant reason → notes
convertOfferToEmployee offer + applicant + posting + new Employee/User atomic txn; transitive employee.created event

No GL legs, no stock ledger, no notifications anywhere.


5. Permissions

  • Feature gate: @RequireFeature('RECRUITMENT_MODULE') + GqlFeatureGuard on all four resolvers (seeded subscription/subscription.seed.ts). Separate from HR_MODULE.
  • RBAC seed: RecruitmentSeedService.seed() reads RECRUITMENT_MODULES_CONFIG (constants.ts) and upserts permission modules + actions (with ignoreCompanyId: true, global):
Module (name / key) Actions
Job Postings / job-postings create, read, update, delete, open, close, mark_filled
Job Applicants / job-applicants create, read, move_to_interview, make_offer, reject
Interview Schedules / interview-schedules create, read, complete, cancel, mark_no_show
Job Offers / job-offers create, read, update, send, accept, reject, convert_to_employee
  • Enforcement asymmetry (flag): only JobOfferResolver declares per-action permission subjects in @ApGqlAuthorize({ permission: {...} }). The posting/applicant/interview resolvers use bare @ApGqlAuthorize() (JWT only), so their seeded actions are enforced only in the admin UI (USER_ACCESS button gating), not server-side. See permissions-access.

6. Flows

6.1 Create + open a posting

Admin /hr/recruitment/postings → "New Posting" → CreateJobPosting (Formik)
  → createJobPosting(input) → service.create({ ...input, createdBy: user.id }) → JobPosting(DRAFT)
Detail /postings/[id] → "Open" → openJobPosting(postingId) → guard DRAFT → status=OPEN, openedAt

6.2 Applicant intake → interview → complete

/applicants → "New Applicant" → createJobApplicant(input) → dedup(email,posting) → JobApplicant(APPLIED)
(edit detail status → SCREENING via updateJobApplicant)        [free-form, see §4.2]
moveApplicantToInterview(applicantId) → guard SCREENING → INTERVIEW
/interviews → "Schedule" → scheduleInterview(input) → InterviewSchedule(SCHEDULED)
/interviews/[id] → "Complete" form (rating + feedback) → completeInterview(id, rating, feedback) → COMPLETED

6.3 Offer → accept → convert

/offers → "New Offer" → createJobOffer(input) → JobOffer(DRAFT)
/offers/[id] → "Send Offer"   (status DRAFT)  → sendOffer    → SENT
            → "Accept"        (status SENT)   → acceptOffer  → ACCEPTED
            → "Convert to Employee" (ACCEPTED & !employeeId)
                → ConvertToEmployeeModal (4-item pre-boarding checklist; all must be checked)
                → convertOfferToEmployee(offerId)
                → Employee+User created; applicant HIRED; posting FILLED
                → header shows "✓ Applicant has been converted to an Employee"

6.4 Unhappy paths

  • Open non-DRAFT posting → "Only draft postings can be opened".
  • Duplicate application → "You have already applied for this position" (400).
  • moveApplicantToInterview when not SCREENING → "Only applicants in screening can move to interview".
  • Send non-DRAFT / accept non-SENT / accept expired → respective errors (§4.4).
  • Convert: not ACCEPTED (400) · already converted (409) · applicant already employee (409) · missing fields (400) · start date past / >2yr (plain Error). Admin routes the GraphQL error through toastSvc.graphQlError and keeps the modal open.

7. Admin UI

One context (context.tsxuseRecruitmentState()) + one GraphQL hook (gql/query.tsuseRecruitmentQuery()) drive all four areas. Context methods:

  • Postings: fetchJobPostings, createJobPosting, updateJobPosting, deleteJobPosting, openJobPosting, closeJobPosting, markJobPostingFilled, getJobPostingById, getApplicantsByPosting, searchJobPostings.
  • Applicants: fetchJobApplicants, createJobApplicant, updateJobApplicant, moveApplicantToInterview, makeOffer, rejectApplicant, getApplicantById, searchApplicants, uploadApplicantFile, deleteApplicantFile.
  • Interviews: fetchInterviewSchedules, scheduleInterview, completeInterview, cancelInterview, markInterviewNoShow, getInterviewById.
  • Offers: fetchJobOffers, createJobOffer, updateJobOffer, sendOffer, acceptOffer, rejectOffer, convertOfferToEmployee, getJobOfferById.

Pages & key components:

Screen Notable UX
postings-page.tsx Table + keyword/status filter; create/edit modals (create-posting.tsx, edit-posting.tsx); status Tag colors; row delete with confirm. Action buttons gated by USER_ACCESS.JOB_POSTINGS.*.
postings-detail.tsx Posting details + nested applicants table (getApplicantsByPosting, paginated); Open / Close / Mark Filled buttons calling the lifecycle methods.
applicants-page.tsx Avatar-initials table; keyword/status filter; create modal (create-applicant.tsx).
applicants-detail.tsx Inline Formik edit (updateJobApplicant) incl. status dropdown (all ApplicantStatus) + source; resume/document attachments via ApFileInputuploadApplicantFile/deleteApplicantFile (accepts pdf/doc/img, max 10).
interviews-page.tsx / interviews-detail.tsx Schedule modal (create-interview.tsx); detail captures rating + feedback then completeInterview.
offers-page.tsx Table + filter; create/edit modal (create-offer.tsx).
offers-detail.tsx Status-driven action buttons: Send (DRAFT) · Accept/Reject (SENT) · Convert to Employee (ACCEPTED & !employeeId) · Edit (while !employeeId); converted banner once employeeId set.
convert-to-employee-modal.tsx 4-checkbox pre-boarding checklist (pre-boarding docs / background / references / documents) — all must be checked to enable Convert; shows applicant/position/start/salary summary.

Form schemas (Formik + Yup):

  • create-offer.tsx: requires applicant, jobPosting, position (master), baseSalary (positive number), currency (master), startDate. Submits position/currency as the master item .name (free strings).
  • create-interview.tsx: requires applicant, jobPosting, interviewType (master interview_type, submitted as .name), scheduledAt; optional interviewerId (employee select).
  • applicants-detail.tsx edit: requires fullName, email, phoneNumber.

Dormant UI methods (flag): moveApplicantToInterview, makeOffer, and rejectApplicant are in the context + GraphQL layer but no component renders a button that calls them. Applicant status is changed in practice only via the detail-page status dropdown (updateJobApplicant). Interview cancel/markNoShow are likewise context-only with no list/detail trigger found.


8. Dependencies & integrations

Calls into:

  • master (MasterService) — resolve department/position names (job-posting).
  • employee (EmployeeService) — getNameById (interviewer) and create (conversion). Hard dependency of job-offer + interview-schedule.
  • upload (FileUploadService) — applicant attachments by refId.
  • subscription — feature gate; auth — JWT; permission — RBAC seed + offer action guards.
  • ApContextServiceuserId for conversionCompletedBy; TransactionManager — conversion txn.

Called by: the employee module is the downstream target of conversion (not a caller). No other module depends on recruitment.

Events: none emitted/consumed directly; conversion transitively triggers employee.created via EmployeeService.create.

Cron / external services: none. (No mailer despite legacy "Phase 3" notification plans.)


9. Gotchas & project-specific rules

  1. makeOffer ≠ create offer. The applicant-side makeOffer mutation only flips ApplicantStatus → OFFER_MADE; the JobOffer document is created separately via createJobOffer. The two are not linked automatically — nothing sets the applicant to OFFER_MADE when a JobOffer is created.
  2. APPLIED → SCREENING has no dedicated mutation and moveApplicantToInterview requires SCREENING — so the dedicated pipeline mutations can't run end-to-end; you reach SCREENING only via free-form updateJobApplicant. (Legacy doc's "APPLIED→INTERVIEW direct" is incorrect.)
  3. OfferStatus.EXPIRED is never set by any code path. Expiry is only checked reactively (accept/convert reject expired offers); no job or mutation flips status to EXPIRED. Treat it as a value the system can store but never auto-produces.
  4. Pipeline mutations are dormant in the adminmoveApplicantToInterview/makeOffer/rejectApplicant (and interview cancel/markNoShow) are wired in context/GraphQL but no UI calls them.
  5. OfferConversionResult + ConvertOfferToEmployeeInput are dead DTOs — declared in recruitment.dto.ts, used by nothing. The mutation returns JobOffer! and takes a bare offerId.
  6. employmentType is hardcoded 'FULL_TIME' in the conversion mapper — the offer has no employment-type field, so every converted hire is full-time until edited in the employee module.
  7. interviewType is a free string, not the InterviewType enum. The enum exists in the schema file but is unused and unregistered in GraphQL; the admin sources values from master data (interview_type).
  8. feedback is a plain string + single numeric rating, not a structured JSON object (corrects the legacy doc).
  9. Admin ↔︎ BE enum drift: admin model.ts has InterviewType = PHONE|VIDEO|IN_PERSON (unused) and OfferStatus.WITHDRAWN (BE has EXPIRED, no WITHDRAWN). Keep the BE enums as source of truth.
  10. Per-action RBAC only on offers. Posting/applicant/interview resolvers are JWT-only server-side; their permission actions are UI gating only.
  11. createJobApplicant is admin-authenticated — there is no public/anonymous candidate apply endpoint despite "appears in job board" language in the legacy doc.
  12. Only conversion is transactional. Every other status transition is a single non-transactional repo.update; a partial multi-write desync is possible only in conversion's guarded txn (which rolls back on any throw).
  13. close/markFilled have no source-status guard — a posting can be closed from any state and filled from any state; there is no reopen path.
  14. Dates are unix-ms numbers throughout (openedAt, closedAt, appliedAt, scheduledAt, startDate, validUntil, sentAt, acceptedAt, hiredAt, conversionCompletedAt).