Skip to main content

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:

StatusMeaning
DRAFTIncomplete posting, not visible in searches
NEWPublished and accepting applications
IN_PROGRESSActively being filled (candidates in pipeline)
PENDING_FEEDBACKAwaiting hiring manager or client feedback on candidates
FUTURE_REQUESTSPaused for future consideration; candidates retained for later matching
CLOSEDPosition 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.

Status regression guard (SUP-00129, 2026-04-21)

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:

FieldRoleDescription
companyIdOwnerThe company that created and owns the posting
hiringCompanyIdHiringThe company where the candidate will work
suppliedByIdSupplierThe 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:

FieldPublic impact
hideHiringCompanyhiringCompanyId 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.
hideSuppliedBysuppliedById, suppliedBy, suppliedByContactId and suppliedByContact are nulled on the public serializer.
hideRateOutrateOut, 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).

Unregistered employers & the save gate (BUG-13)

Some contacts have an employer that is only free-text — a companyNameRaw value with no registered Company FK. The Hiring Company / Supplied By pickers now surface these as virtual entries labelled "<raw> · not on platform yet", keyed with the sentinel external:<contactId> so they're distinguishable from real Company ids.

Picking a virtual entry does not silently fall through to myCompanies and it does not auto-create a Company. Instead, CreateJob.blockOnExternalPick() blocks both Create and Save Draft when either picker holds an external: sentinel: the wizard jumps back to step 2 and shows a warning — "…isn't on the platform yet…Pick a registered company, or register this one first."

No Company is auto-created from contact free-text (BUG-13)

Free-text employer names are intentionally not promoted to Company records automatically — doing so would spawn spelling-variant duplicates and orphaned records. To attach a job to such an employer you must first register the company (for example as a company shell). Backend support to resolve these picks is tracked as a handoff.

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:

FieldMeaning
startDateCalendar day the engagement / employment starts
endDateCalendar day the engagement ends (auto-calculated on the frontend from startDate + length)
dueDateApplication deadline — the last day a candidate can apply
Invariant: Due Date cannot be in the past (SUP-00051, 2026-04-21)

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.

Invariant: Due Date <= Start Date (SUP-00127, 2026-04-17)

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 directly
  • update() — loads the existing job, merges the patch (updateJobInput.field ?? existingJob.field), and validates the result, so a patch that only touches dueDate is still checked against the persisted startDate

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 }
}
info

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.

Job, JobSkillRequirement, JobLocation, JobBenefit, JobSavedStatus

Relationships: Company (owner, hiring, supplier), Contact (responsible), User (responsible), PipelineItem (candidate workflow).