Job Management
Jobs are the central entity in the recruitment workflow. They define open positions with skill requirements, link to one or more companies in different roles, and serve as the target for candidate applications and match scoring.
Job Lifecycle
Jobs progress through six statuses plus an orthogonal archivedAt soft-delete flag:
| Status | Meaning |
|---|---|
DRAFT | Incomplete posting, not visible in searches |
NEW | Published and accepting applications |
IN_PROGRESS | Actively being filled (candidates in pipeline) |
PENDING_FEEDBACK | Awaiting hiring manager or client feedback on candidates |
FUTURE_REQUESTS | Paused for future consideration; candidates retained for later matching |
CLOSED | Position filled or cancelled; notifies all linked candidates |
Status changes emit a job.status.changed event that triggers notifications to recruiters and, when closing, to all associated candidates.
JobService.update() rejects transitions from any later status back to NEW when Job.dueDate has already passed. The deadline has moved on, so re-opening the slot for new applicants would be misleading. Violation throws BadRequestException("Cannot move a job back to NEW after its due date has passed").
Archive vs. hard delete (SUP-00083, 2026-04-21)
removeJob is now a soft-archive: it sets Job.archivedAt = now() and emits a job.archived event instead of cascading a delete. Pipeline items, candidates and skill requirements stay intact so historical data survives, but the row is hidden from the default opportunities / allJobs queries via where: { archivedAt: null }.
The job.archived handler in RecruitmentNotificationListener reuses the existing notifyCandidatesOfJobClosure path to email invited candidates — they're told the posting has been withdrawn without losing their pipeline history.
Company Roles
Each job links to up to three companies, enabling multi-party recruitment:
| Field | Role | Description |
|---|---|---|
companyId | Owner | The company that created and owns the posting |
hiringCompanyId | Hiring | The company where the candidate will work |
suppliedById | Supplier | The staffing agency supplying candidates |
All three can be the same company for direct hiring, or different for agency-assisted placements.
Public-view visibility toggles (SUP-00121/122/125/126/106, 2026-04-21)
Three Boolean flags on Job control what external (unauthenticated) visitors see when browsing the public listing:
| Field | Public impact |
|---|---|
hideHiringCompany | hiringCompanyId is rewritten to the posting company's own id; hiringCompanyContactId / hiringCompanyContact are nulled. External viewers see "Posted by <your company>" instead of the real client. |
hideSuppliedBy | suppliedById, suppliedBy, suppliedByContactId and suppliedByContact are nulled on the public serializer. |
hideRateOut | rateOut, rateOutFrequency and rateOutCurrency are nulled on the public serializer (used by freelance postings that want to keep the client rate internal). |
The masking happens in a dedicated src/job-management/job/public-job.serializer.ts that wraps findAllJobs and findOnePublic — the underlying row is never mutated, so internal opportunities / job(id) queries (authenticated company members) still see everything. The job form (components/jobs/JobRequirementsForm.tsx) surfaces three checkboxes below the relevant sections; both createJob and updateJob carry the flags through.
On the frontend, Rate Out is rendered on the two external job views:
- Public page
/jobs/[jobId](visitor route, unauthenticated) — "Rate Out (Client Rate)" cell in Position Details. - Candidate page
/jobseeker/jobs/[jobId](authenticated jobseeker) — "Client Rate" row in the Position sidebar.
Both render conditionally on job.rateOut != null — so when hideRateOut=true causes the serializer to null the field, the row simply drops out of the layout without any "null" placeholder. The company view /company/jobs/[jobId] reads from the non-serialized job(id) query and always shows Rate Out to internal users.
Filtered company dropdowns (SUP-00121/122)
The Hiring Company and Supplied By dropdowns are sourced from the company's Contacts rather than its company list:
- Hiring Company dropdown →
contactsByLabel("Client") - Supplied By dropdown →
contactsByLabel("Partner")
Each Contact resolves to a Company id via userCompanyRole.companyId (primary — where the contact person works) or clients[0].id (fallback — the first linked client company). If no labelled contacts exist yet, the form falls through to the previous myCompanies list so nobody is locked out while contact labels are being backfilled.
Skill Requirements
Jobs define skill requirements via JobSkillRequirement, each specifying whether the skill is required or preferred and the expected proficiency level.
Proficiency levels: BASIC (1), INTERMEDIATE (2), ADVANCED (3), EXPERT (4)
These requirements feed directly into the matching engine. When a user views a job, the myMatchScore field resolver computes a real-time score comparing the user's skills against the job requirements.
Date fields & cross-field validation
Three DateTime? fields live on Job:
| Field | Meaning |
|---|---|
startDate | Calendar day the engagement / employment starts |
endDate | Calendar day the engagement ends (auto-calculated on the frontend from startDate + length) |
dueDate | Application deadline — the last day a candidate can apply |
CreateJobInput.dueDate / UpdateJobInput.dueDate carry @IsFutureDate (see src/common/validators/is-future-date.validator.ts). Attempts to set or move the Due Date to a day before today (UTC) are rejected with VALIDATION_FAILED / Date cannot be in the past. The check compares dates at day-UTC precision so a Due Date of "today" is accepted until the day ends.
JobService.create() and update() enforce that the application Due Date cannot fall after the Start Date — applications must close on or before the job begins.
Comparison is done at day-UTC precision, so a startDate of 2026-06-01T08:00:00Z with a dueDate of 2026-06-01T23:59:00Z (same calendar day, different times) is accepted. Either field being null or missing skips the check — the invariant fires only when both are present.
Enforcement sites in src/job-management/job/job.service.ts:
create()— validates the incoming input directlyupdate()— loads the existing job, merges the patch (updateJobInput.field ?? existingJob.field), and validates the result, so a patch that only touchesdueDateis still checked against the persistedstartDate
Violation shape (via FieldValidationException):
{
"message": "Validation failed: Due Date cannot be later than Start Date",
"extensions": {
"code": "VALIDATION_FAILED",
"statusCode": 400,
"details": {
"fieldErrors": [
{ "field": "dueDate", "message": "Due Date cannot be later than Start Date", "value": "2026-07-01" }
]
}
}
}
The frontend job-creation wizard (components/reusable/jobs/CreateJob.tsx step 3 + components/jobs/JobRequirementsForm.tsx) enforces the same invariant client-side — the Next button is gated, the final Save button double-checks, and an inline red alert surfaces under the Due Date picker when the pair is invalid.
Key Queries
# Jobs visible to the current user (respects selected company)
query {
opportunities {
id title status
company { id name }
skillRequirements { skill { name } required proficiency }
myMatchScore
}
}
# Public jobs (no auth required)
query {
publicJobs {
id title company { name logo }
}
}
Key Mutations
# Create a job (enforces plan limit)
mutation {
createJob(createJobInput: {
title: "Senior Backend Engineer"
companyId: "company-uuid"
hiringCompanyId: "hiring-company-uuid"
# Public-view visibility toggles (SUP-00121/122/125/126/106)
hideHiringCompany: true
hideSuppliedBy: false
hideRateOut: false
skillRequirements: [
{ skillId: "skill-uuid", required: true, proficiency: ADVANCED }
]
}) { id title status hideHiringCompany archivedAt }
}
# Soft-archive a job (SUP-00083). Keeps candidates/pipeline intact;
# emits job.archived event so invited candidates are notified.
mutation { removeJob(id: "job-uuid") { success message } }
# Duplicate a job to another company
mutation {
duplicateJob(duplicateJobInput: {
jobId: "job-uuid"
targetCompanyIds: ["company-b", "company-c"]
}) { id title }
}
Job creation is gated by @CheckPlanLimit('maxJobs'). The plan limit determines how many active jobs a company can maintain.
Inbound Email Ingestion
Jobs can receive candidate applications via email using plus-addressing:
jobs+{company-slug}@aiqlick.com
The company slug is extracted from the email address, normalized (lowercase, hyphens, no special characters), and resolved against Company.slug. Attached CVs are stored in S3 and processed through the AI parsing pipeline, then linked to the relevant job.
Processing flow: IMAP poll (background-tasks) --> company resolution --> attachment storage (S3) --> CV parse attempt --> job linking
Saved Jobs
Users can bookmark jobs for later review. The JobSavedStatus model tracks per-user saved state, toggled via updateJobSavedStatus.
Related Prisma Models
Job, JobSkillRequirement, JobLocation, JobBenefit, JobSavedStatus
Relationships: Company (owner, hiring, supplier), Contact (responsible), User (responsible), PipelineItem (candidate workflow).