Recruitment — job postings, applicants, interviews & offers
The whole recruitment module reduces to one idea: four independent, soft-deletable
BaseSchemacollections, each with its own status enum, linked only byObjectIdreferences — and one transactional bridge (convertOfferToEmployee) that turns an accepted offer into a liveEmployee. 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 JobPosting — jobpostings
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 JobApplicant — jobapplicants
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 InterviewSchedule — interviewschedules
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
feedbackis a JSON object ({"technicalScore": 8}). In codefeedbackis a plainstring(GraphQLString), andratingis a single number. There is no structured-feedback JSON.
Indexes: { applicantId }, { jobPostingId }, { scheduledAt }, { ref } unique.
2.4 JobOffer — joboffers
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(notmakeJobOffer); the create mutation iscreateJobApplicanttaking onlyinput(nostoreIdarg).CreateJobApplicantInput:jobPostingId, email, fullName, phoneNumber, city?, resumeUrl?, coverLetter?, notes?, source?.UpdateJobApplicantInputadds editablestatus?.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:
completeInterviewtakesid(notinterviewId);feedbackis a requiredStringarg (not JSON); there is noscheduleInterview(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.tsdeclaresConvertOfferToEmployeeInputandOfferConversionResult { success, message, employeeId?, updatedOffer? }, but no resolver uses them —convertOfferToEmployeetakes a plainofferId: String!arg and returnsJobOffer!. 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
openenforces a source-status guard.closeandmarkFilledaccept 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"); elsesuper.create. Dedup key =(email, jobPostingId).moveToInterview(id): load; requirestatus == 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 → SCREENINGgap (confirmed): no dedicated mutation setsSCREENING. The only ways: (a)updateJobApplicantwith a free-formstatusvalue, or (b) the admin detail-page status dropdown (which callsupdateJobApplicant). SincemoveToInterviewrequiresSCREENING, 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')+GqlFeatureGuardon all four resolvers (seededsubscription/subscription.seed.ts). Separate fromHR_MODULE. - RBAC seed:
RecruitmentSeedService.seed()readsRECRUITMENT_MODULES_CONFIG(constants.ts) and upserts permission modules + actions (withignoreCompanyId: 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
JobOfferResolverdeclares 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_ACCESSbutton 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). moveApplicantToInterviewwhen 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.graphQlErrorand keeps the modal open.
7. Admin UI
One context (context.tsx → useRecruitmentState()) + one GraphQL hook (gql/query.ts → useRecruitmentQuery()) 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 ApFileInput → uploadApplicantFile/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: requiresapplicant,jobPosting,position(master),baseSalary(positive number),currency(master),startDate. Submitsposition/currencyas the master item.name(free strings).create-interview.tsx: requiresapplicant,jobPosting,interviewType(masterinterview_type, submitted as.name),scheduledAt; optionalinterviewerId(employee select).applicants-detail.tsxedit: requiresfullName,email,phoneNumber.
Dormant UI methods (flag):
moveApplicantToInterview,makeOffer, andrejectApplicantare 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). Interviewcancel/markNoShoware likewise context-only with no list/detail trigger found.
8. Dependencies & integrations
Calls into:
- master (
MasterService) — resolvedepartment/positionnames (job-posting). - employee (
EmployeeService) —getNameById(interviewer) andcreate(conversion). Hard dependency ofjob-offer+interview-schedule. - upload (
FileUploadService) — applicant attachments byrefId. - subscription — feature gate; auth — JWT; permission — RBAC seed + offer action guards.
ApContextService—userIdforconversionCompletedBy;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
makeOffer≠ create offer. The applicant-sidemakeOffermutation only flipsApplicantStatus → OFFER_MADE; theJobOfferdocument is created separately viacreateJobOffer. The two are not linked automatically — nothing sets the applicant toOFFER_MADEwhen aJobOfferis created.APPLIED → SCREENINGhas no dedicated mutation andmoveApplicantToInterviewrequiresSCREENING— so the dedicated pipeline mutations can't run end-to-end; you reachSCREENINGonly via free-formupdateJobApplicant. (Legacy doc's "APPLIED→INTERVIEW direct" is incorrect.)OfferStatus.EXPIREDis never set by any code path. Expiry is only checked reactively (accept/convert reject expired offers); no job or mutation flips status toEXPIRED. Treat it as a value the system can store but never auto-produces.- Pipeline mutations are dormant in the admin —
moveApplicantToInterview/makeOffer/rejectApplicant(and interviewcancel/markNoShow) are wired in context/GraphQL but no UI calls them. OfferConversionResult+ConvertOfferToEmployeeInputare dead DTOs — declared inrecruitment.dto.ts, used by nothing. The mutation returnsJobOffer!and takes a bareofferId.employmentTypeis 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.interviewTypeis a free string, not theInterviewTypeenum. The enum exists in the schema file but is unused and unregistered in GraphQL; the admin sources values from master data (interview_type).feedbackis a plain string + single numericrating, not a structured JSON object (corrects the legacy doc).- Admin ↔︎ BE enum drift: admin
model.tshasInterviewType = PHONE|VIDEO|IN_PERSON(unused) andOfferStatus.WITHDRAWN(BE hasEXPIRED, noWITHDRAWN). Keep the BE enums as source of truth. - Per-action RBAC only on offers. Posting/applicant/interview resolvers are JWT-only server-side; their permission actions are UI gating only.
createJobApplicantis admin-authenticated — there is no public/anonymous candidate apply endpoint despite "appears in job board" language in the legacy doc.- 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). close/markFilledhave no source-status guard — a posting can be closed from any state and filled from any state; there is no reopen path.- Dates are unix-ms numbers throughout (
openedAt,closedAt,appliedAt,scheduledAt,startDate,validUntil,sentAt,acceptedAt,hiredAt,conversionCompletedAt).