Skip to content

Mr. Hire — Talent Pool & Salary Benchmarking

This document describes two closely-related Mr. Hire (recruitment / ATS) sub-domains inside mr-mentor-backend: the Talent Pool (a recruiter-owned bench of previously-screened candidates, plus a two-stage engine that re-matches those candidates against new job posts) and Salary Benchmarking (an LLM-sourced reference table of market salary ranges per role and experience band, refreshed on a schedule). Both are recruiter-facing helpers that sit on top of the core jobs-and-applications pipeline: the Talent Pool recycles candidates who did not get shortlisted for one job into pipelines for other jobs, and Salary Benchmarking gives recruiters a market reference when defining compensation for a role.

Status: documented from source on this branch.


Overview

Sub-domain What it does Primary users
Talent Pool Keeps a per-recruiter bench of candidates (auto-curated from screening + manually added), tags/notes them, and re-matches them to new job posts with a score, so a candidate rejected for job A can be invited into job B. Recruiter / HR user (the belongsTo owner of a job post), ADMIN
Salary Benchmarking Maintains a table of market salary ranges keyed by (role, experienceYears), populated by a single LLM call to mr-hire-backend, surfaced to recruiters as a read-only reference and refreshed every 15 days. Recruiter / HR user (read), ADMIN (refresh)

Where it sits in the suite:

  • The Talent Pool is downstream of resume screening. When the AI resume-analysis worker finishes scoring an application, it silently drops "decent but not shortlisted" candidates into the talent pool (see Background jobs & async). Recruiters can also add candidates by hand.
  • Matching bridges the talent pool back to the jobs domain: given a jobPostId, it filters the recruiter's pool by skill overlap and scores each candidate, producing TalentPoolMatch rows that a recruiter can act on.
  • Inviting a matched candidate spawns a brand-new JobApplication on the target job (source talent_pool), copies the old resume screening scores, generates a fresh quiz, and emails the candidate — re-entering the standard pipeline documented in the jobs-and-applications doc.
  • Salary benchmarks are an independent reference dataset, derived from job posts / job templates / built-in role templates, and consumed by the recruiter UI when posting jobs.

Both domains lean on the Python mr-hire-backend (FastAPI, port 8001) for LLM work and degrade gracefully to local heuristics when it is unavailable.


Key concepts & entities

Glossary

  • Talent pool entry — a single candidate parked on a recruiter's bench. Snapshots the candidate's parsed skills, experience and current role at the time of entry, plus a score and a reason for why they landed there.
  • belongsTo — the HR/recruiter user id that owns the entry (resolved from the originating job post's owner). All pool listing and matching is scoped to this owner.
  • reason — why the entry exists: auto_rejected (auto-rejected by screening but score still decent), not_shortlisted (scored between the reject and shortlist thresholds), or manual_add (recruiter added it).
  • Talent pool match — a scored pairing of one pool entry to one job post, with a matchScore (0-100), a human-readable matchReason, and a status (suggested / invited / dismissed).
  • Two-stage matching — Stage 1 is a cheap DB filter (skill overlap + owner filter); Stage 2 is LLM batch scoring with a local JS fallback. Only candidates scoring >= 30 become matches.
  • Salary benchmark — a market salary range for a (role, experienceYears) pair: industryMin / industryMax in a currency (default INR), plus optional marketData percentiles and LLM reasoning.
  • Benchmark refresh — the batch job that collects all candidate roles, asks the LLM for ranges in one call, and upserts the results.

TypeORM entities

Entity Table File
TalentPoolEntry talent_pool_entries src/entities/TalentPoolEntry.entity.ts
TalentPoolMatch talent_pool_matches src/entities/TalentPoolMatch.entity.ts
SalaryBenchmark salary_benchmarks src/entities/SalaryBenchmark.ts

Related (owned by the jobs-and-applications domain, referenced here):

Entity File Why it matters
JobApplication src/entities/JobApplication.ts A pool entry points at one application; inviting a match creates a new one.
JobPost src/entities/JobPost.ts Source of required skills (tools + roles), experienceRequired, and belongsTo owner.
ScreeningResult src/entities/ScreeningResult.entity.ts Source of parsed skills/experience/role + scores snapshotted into the pool entry.
JobTemplate src/entities/JobTemplate.ts A role source for benchmark collection.

Notable columns / constraints:

  • TalentPoolEntry.applicationId is unique (@Index(['applicationId'], { unique: true })) — one pool entry per application. Adding the same application twice returns 409.
  • TalentPoolEntry.parsedSkills is jsonb (used for the PostgreSQL ?| array-overlap filter); parsedExperience is stored in months.
  • SalaryBenchmark has a unique composite index on (role, experienceYears)upsert keys off this pair.
  • Foreign keys: TalentPoolEntry.application is onDelete: CASCADE; TalentPoolEntry.jobPost is onDelete: SET NULL (nullable); TalentPoolMatch cascades on delete of both its entry and its job post.

Architecture

flowchart TD
  subgraph Client["Recruiter UI (mr-mentor-frontend)"]
    UI["Talent Pool & Salary screens"]
  end

  subgraph Routes["Express routes (mount /api)"]
    TPR["talentPool.routes.ts"]
    SBR["salaryBenchmark.routes.ts"]
  end

  subgraph MW["Middleware"]
    AUTH["authMiddleware (JWT)"]
    ADMIN["adminMiddleware (ADMIN only)"]
  end

  subgraph Ctrl["Controllers"]
    TPC["TalentPoolController.ts"]
    SBC["SalaryBenchmarkController.ts"]
  end

  subgraph Svc["Services & helpers"]
    SBS["SalaryBenchmarkService.ts"]
    QS["QueueService"]
    GQT["generateQuizToken helper"]
  end

  subgraph DB["PostgreSQL (TypeORM)"]
    TPE["talent_pool_entries"]
    TPM["talent_pool_matches"]
    SB["salary_benchmarks"]
    JA["job_applications"]
    SRT["screening_results"]
    JP["job_posts"]
  end

  subgraph Async["Redis / BullMQ"]
    SBQ["salaryBenchmarkQueue (repeat 15d)"]
    SBW["salaryBenchmark.worker"]
    EQ["emailQueue"]
  end

  subgraph Ext["mr-hire-backend (FastAPI :8001)"]
    SCORE["score-match-batch"]
    QGEN["quiz/generate"]
    SEST["salary-benchmarks/estimate"]
  end

  UI --> TPR --> AUTH --> TPC
  UI --> SBR --> AUTH
  SBR --> ADMIN
  SBR --> SBC

  TPC --> TPE
  TPC --> TPM
  TPC --> JA
  TPC --> SRT
  TPC --> JP
  TPC -->|"Stage 2 scoring"| SCORE
  TPC -->|"invite: generate quiz"| QGEN
  TPC --> GQT
  TPC -->|"invite email"| QS --> EQ

  SBC --> SBS
  SBS --> SB
  SBS --> JP
  SBS -->|"estimate ranges"| SEST
  QS -->|"schedule"| SBQ --> SBW --> SBS

Data model

erDiagram
  JOB_POST ||--o{ JOB_APPLICATION : "receives"
  JOB_APPLICATION ||--o| TALENT_POOL_ENTRY : "parked as"
  JOB_APPLICATION ||--o{ SCREENING_RESULT : "scored by"
  TALENT_POOL_ENTRY }o--o| JOB_POST : "originated from"
  TALENT_POOL_ENTRY ||--o{ TALENT_POOL_MATCH : "matched via"
  JOB_POST ||--o{ TALENT_POOL_MATCH : "matched against"

  TALENT_POOL_ENTRY {
    uuid id PK
    uuid applicationId UK
    uuid jobPostId FK
    uuid belongsTo
    float score
    string reason
    json tags
    text notes
    timestamp availability
    jsonb parsedSkills
    int parsedExperience
    string parsedCurrentRole
  }
  TALENT_POOL_MATCH {
    uuid id PK
    uuid talentPoolEntryId FK
    uuid jobPostId FK
    float matchScore
    text matchReason
    string status
    timestamp createdAt
  }
  SALARY_BENCHMARK {
    uuid id PK
    string role
    int experienceYears
    int industryMin
    int industryMax
    string currency
    string source
    uuid sourceId
    text reasoning
    jsonb marketData
    boolean isActive
  }
  JOB_APPLICATION {
    uuid id PK
    uuid jobPostId FK
    string fullName
    string email
    string status
    string source
  }
  SCREENING_RESULT {
    uuid id PK
    uuid applicationId FK
    float finalScore
    float overallResumeScore
    jsonb parsedSkills
    int parsedExperienceMonths
    string parsedCurrentRole
  }
  JOB_POST {
    uuid id PK
    uuid belongsTo
    string name
    json roles
    json tools
    int experienceRequired
    string status
  }

Notable enum-like string fields:

  • TalentPoolEntry.reason: auto_rejected | not_shortlisted | manual_add.
  • TalentPoolMatch.status: suggested (default) | invited | dismissed.
  • SalaryBenchmark.source: free text, defaults to llm (e.g. "Glassdoor India, AmbitionBox").
  • SalaryBenchmark.isActive: only active rows are listed / served.

SalaryBenchmark is intentionally standalone — it has no foreign keys. sourceId is a loose pointer to the job post or template that prompted the benchmark and is not a DB-enforced relation.


API surface

All talent-pool and salary routes are mounted under /api in src/routes/index.ts (this.router.use('/api', this.talentPoolRoutes.router) and .use('/api', this.salaryBenchmarkRoutes.router)). Paths below are the full request paths.

Talent Pool (src/routes/talentPool.routes.ts)

Method Path Auth/role Purpose
GET /api/mr-hire/talent-pool authMiddleware List the current user's pool entries, each enriched with its latest screening snapshot.
POST /api/mr-hire/talent-pool authMiddleware Manually add a candidate (applicationId, optional tags, notes) — reason: manual_add.
DELETE /api/mr-hire/talent-pool/:id authMiddleware Remove a pool entry by id.
PUT /api/mr-hire/talent-pool/:id/tags authMiddleware Update tags and/or notes on an entry.
GET /api/mr-hire/talent-pool/matches authMiddleware List all suggested matches (joined to entry, application, job post), ordered by score.
POST /api/mr-hire/talent-pool/matches/:matchId/invite authMiddleware Invite a matched candidate: create new application, copy scores, generate quiz, email candidate, mark match invited.
POST /api/mr-hire/talent-pool/matches/:matchId/dismiss authMiddleware Mark a match dismissed.
POST /api/mr-hire/talent-pool/run-matching/:jobPostId authMiddleware Run two-stage matching for one job against the user's pool; refresh suggested matches.

Salary Benchmarks (src/routes/salaryBenchmark.routes.ts)

Method Path Auth/role Purpose
GET /api/hr/salary-benchmarks authMiddleware List active benchmarks filtered to roles that have active job posts.
GET /api/hr/salary-benchmarks/:role authMiddleware List active benchmarks for a single role (param is URL-decoded), ordered by experience.
POST /api/admin/salary-benchmarks/refresh authMiddleware + adminMiddleware Synchronously trigger an LLM benchmark refresh; returns { updated, failed }.

Note on roles: adminMiddleware allows only UserRole.ADMIN (src/middleware/admin.middleware.ts), which is treated as the universal-access role. All other talent-pool/benchmark endpoints require only a valid JWT (authMiddleware); there is no explicit recruiter role check at the route layer — ownership is enforced inside the controller via belongsTo scoping.


User journeys

Journey 1 — A candidate is auto-curated into the talent pool

After AI resume screening completes, candidates that are neither clearly shortlisted nor clearly rejected are silently benched so the recruiter can recycle them later. This happens inside the resume-analysis worker (src/workers/resumeAnalysis.worker.ts, addToTalentPoolIfEligible) — no UI action needed.

sequenceDiagram
  participant W as resumeAnalysis worker
  participant SR as screening_results
  participant JP as job_posts
  participant TPE as talent_pool_entries

  Note over W: screening finished with a finalScore
  W->>TPE: check existing entry for applicationId
  alt already in pool
    TPE-->>W: entry exists, skip
  else not yet pooled
    W->>JP: load job post to resolve belongsTo owner
    JP-->>W: owner id or null
    alt score above reject and below shortlist
      W->>TPE: save entry reason not_shortlisted
      Note over W: application status stays scoring_complete for HR review
    else score below reject but above 20
      W->>TPE: save entry reason auto_rejected
      Note over W: application stays rejected in the pipeline
    else score 20 or below
      Note over W: not added to pool
    end
  end

Key points: the entry snapshots parsedSkills, parsedExperienceMonths, and parsedCurrentRole from the screening so later matching does not need to re-read the screening row. belongsTo is copied from the job post's owner so the pool is naturally partitioned per recruiter. The application's pipeline status is deliberately not changed.

Journey 2 — Recruiter manually adds a candidate to the pool

sequenceDiagram
  participant FE as Recruiter UI
  participant API as TalentPoolController.addToPool
  participant TPE as talent_pool_entries
  participant JA as job_applications
  participant SR as screening_results

  FE->>API: POST /api/mr-hire/talent-pool with applicationId tags notes
  alt applicationId missing
    API-->>FE: 400 applicationId is required
  else has applicationId
    API->>TPE: find entry by applicationId
    alt already pooled
      API-->>FE: 409 already in the talent pool
    else not pooled
      API->>JA: load application
      alt application missing
        API-->>FE: 404 Application not found
      else found
        API->>SR: latest screening for application
        SR-->>API: screening snapshot or null
        API->>TPE: save entry reason manual_add with score and parsed fields
        API-->>FE: 201 with saved entry
      end
    end
  end

belongsTo is set to the caller's user id here (not the job post owner), and score falls back from finalScore to overallResumeScore to 0.

Journey 3 — Recruiter lists and curates the pool

sequenceDiagram
  participant FE as Recruiter UI
  participant API as TalentPoolController.getPoolEntries
  participant TPE as talent_pool_entries
  participant SR as screening_results

  FE->>API: GET /api/mr-hire/talent-pool
  API->>TPE: query entries where belongsTo equals me or job post owned by me
  TPE-->>API: entries with application and job post joined
  API->>SR: latest screening per applicationId
  SR-->>API: screening rows newest first
  Note over API: keep first screening per application as the snapshot
  API-->>FE: entries each with a screening object attached

  opt update tags or notes
    FE->>API: PUT /api/mr-hire/talent-pool/:id/tags
    API->>TPE: find by id then save tags and notes
    API-->>FE: 200 updated entry
  end
  opt remove
    FE->>API: DELETE /api/mr-hire/talent-pool/:id
    API->>TPE: remove entry
    API-->>FE: 200 Removed from talent pool
  end

The listing query scopes results with: belongsTo = me OR (belongsTo IS NULL AND the entry's job post is owned by me). This covers both manually-added entries (owner stamped directly) and auto-curated entries whose owner was resolvable from the job post. Tag/notes updates and removal currently look up by id without re-checking ownership (see gotchas).

Journey 4 — Recruiter runs matching for a job

The headline journey. Given a target job, score the recruiter's pool and produce suggested matches.

sequenceDiagram
  participant FE as Recruiter UI
  participant API as TalentPoolController.runMatching
  participant JP as job_posts
  participant TPE as talent_pool_entries
  participant PY as mr-hire-backend score-match-batch
  participant TPM as talent_pool_matches

  FE->>API: POST /api/mr-hire/talent-pool/run-matching/:jobPostId
  API->>JP: load job post
  alt job not found
    API-->>FE: 404 Job post not found
  else found
    Note over API: requiredSkills equals job tools plus roles
    alt no skills defined
      API-->>FE: 400 Job has no tools or roles for matching
    else has skills
      Note over API: Stage 1 DB filter
      API->>TPE: jsonb overlap on parsedSkills filtered by belongsTo
      alt jsonb query throws
        API->>TPE: fallback fetch my entries then filter in JS
      end
      TPE-->>API: filtered candidates
      alt zero candidates
        API->>TPM: delete old suggested matches for this job
        API-->>FE: 200 empty with No candidates match this job
      else have candidates
        Note over API: Stage 2 scoring
        API->>PY: POST candidates plus jobDescription
        alt LLM ok
          PY-->>API: results with id score reason
        else LLM fails or bad format
          Note over API: fallback to simpleMatchScore in JS
        end
        API->>TPM: delete old suggested matches for this job
        Note over API: keep only scores 30 or above
        API->>TPM: bulk save new suggested matches
        API-->>FE: 200 matches plus summary counts and min max score
      end
    end
  end

Scoring detail (simpleMatchScore fallback): skillScore is the fraction of required skills found in the candidate's parsed skills (substring match either direction) times 100; expScore compares candidate experience in months against requiredExp * 12 capped at 100; finalScore = skillScore * 0.7 + expScore * 0.3, rounded. Re-running matching is idempotent per job: it deletes prior suggested matches before inserting fresh ones, so already-invited/dismissed matches are preserved.

Journey 5 — Recruiter invites a matched candidate

Inviting promotes a bench candidate into a real pipeline on the target job.

sequenceDiagram
  participant FE as Recruiter UI
  participant API as TalentPoolController.inviteMatch
  participant TPM as talent_pool_matches
  participant JA as job_applications
  participant SR as screening_results
  participant PY as mr-hire-backend quiz generate
  participant TOK as generateQuizToken
  participant Q as emailQueue

  FE->>API: POST /api/mr-hire/talent-pool/matches/:matchId/invite
  API->>TPM: load match with entry application and job post
  alt match missing
    API-->>FE: 404 Match not found
  else found
    alt status not suggested
      API-->>FE: 400 Match already invited or dismissed
    else suggested
      API->>JA: create new application source talent_pool status quiz_invited
      JA-->>API: saved new application
      API->>SR: load old screening for original application
      API->>SR: create new screening copying resume scores
      API->>PY: POST jd to generate 5 questions
      alt quiz generated
        PY-->>API: quiz questions
        API->>SR: save screening with quizQuestions
        API->>TOK: generate quiz token and link expiry 7 days
        TOK-->>API: quizUrl
        API->>SR: save quizLink and status quiz_sent
        API->>Q: enqueue quiz-invitation email
      else quiz generation failed
        Note over API: continue without quiz invite without questions
      end
      API->>TPM: set match status invited
      API-->>FE: 200 with newApplication quizSent quizUrl
    end
  end

Robustness: quiz generation and token/email steps are wrapped so failures are logged but do not abort the invite — the new application is still created and the match is still marked invited. The auth token from the incoming request is forwarded to the Python quiz endpoint.

Journey 6 — Recruiter dismisses a match

sequenceDiagram
  participant FE as Recruiter UI
  participant API as TalentPoolController.dismissMatch
  participant TPM as talent_pool_matches

  FE->>API: POST /api/mr-hire/talent-pool/matches/:matchId/dismiss
  API->>TPM: find match by id
  alt not found
    API-->>FE: 404 Match not found
  else found
    API->>TPM: set status dismissed
    API-->>FE: 200 with updated match
  end

Journey 7 — Recruiter looks up salary benchmarks for a role

sequenceDiagram
  participant FE as Recruiter UI
  participant API as SalaryBenchmarkController
  participant SBS as SalaryBenchmarkService
  participant SB as salary_benchmarks
  participant JP as job_posts

  alt all benchmarks
    FE->>API: GET /api/hr/salary-benchmarks
    API->>SBS: getAll
    SBS->>JP: load active job posts and collect posted roles
    SBS->>SB: load all active benchmarks
    Note over SBS: drop known company-name rows then keep only roles matching a posted role
    SBS-->>API: filtered benchmarks
  else single role
    FE->>API: GET /api/hr/salary-benchmarks/:role
    API->>SBS: getByRole decoded role
    SBS->>SB: active benchmarks for role ordered by experience
    SBS-->>API: rows
  end
  API-->>FE: 200 with data

getAll deliberately hides benchmarks for roles nobody is hiring for, and uses keyword/word-overlap heuristics plus a hardcoded excludedCompanyNames blocklist to avoid surfacing stray company-name rows that leaked into the table.

Journey 8 — Salary benchmark refresh (manual or scheduled)

sequenceDiagram
  participant T as Trigger admin POST or 15d repeat
  participant API as SalaryBenchmarkController.refresh
  participant SBW as salaryBenchmark.worker
  participant SBS as SalaryBenchmarkService
  participant TMP as job_templates
  participant JP as job_posts
  participant PY as mr-hire-backend salary estimate
  participant SB as salary_benchmarks

  alt manual admin
    T->>API: POST /api/admin/salary-benchmarks/refresh
    API->>SBS: refreshBenchmarks runs inline
  else scheduled
    T->>SBW: salaryBenchmarkQueue job refreshBenchmarks
    SBW->>SBS: refreshBenchmarks
  end
  SBS->>SBS: collect roles from builtin templates
  SBS->>TMP: add user template roles
  SBS->>JP: add active job post roles default 2 years
  SBS->>PY: POST all roles in ONE call timeout 120s
  alt LLM ok
    PY-->>SBS: benchmarks array
  else LLM fails or empty
    Note over SBS: returns empty list nothing saved
  end
  loop each benchmark
    SBS->>SB: upsert by role and experienceYears
  end
  SBS-->>API: updated and failed counts

The LLM is called once for every collected role (not per-role) to keep cost and latency bounded.


Background jobs & async

Mechanism Detail Source
Talent pool auto-curation The resumeAnalysisQueue worker calls addToTalentPoolIfEligible after scoring each application. Pools not_shortlisted (score between reject and shortlist thresholds) and auto_rejected (score <= reject but > 20). Idempotent via the unique applicationId. src/workers/resumeAnalysis.worker.ts
Salary benchmark refresh queue BullMQ queue salaryBenchmarkQueue. Worker concurrency 1; job type refreshBenchmarks. removeOnComplete: 50, removeOnFail: 50. Re-throws on error so BullMQ records the failure. src/workers/salaryBenchmark.worker.ts
Refresh schedule QueueService.scheduleSalaryBenchmarkRefresh() clears existing repeatable jobs then adds a repeatable job every 15 days (15 * 24 * 60 * 60 * 1000 ms). Wired up at startup in src/index.ts (await queueService.scheduleSalaryBenchmarkRefresh()), and the worker is imported there too. src/services/QueueService.ts, src/index.ts
Manual refresh POST /api/admin/salary-benchmarks/refresh runs refreshBenchmarks() synchronously in the request (not via the queue) and returns the counts. src/controllers/SalaryBenchmarkController.ts
Invite email Invite enqueues a quiz-invitation job on the email queue via QueueService.getInstance().addEmailJob(...). src/controllers/TalentPoolController.ts

There are no Socket.IO events or inbound webhooks specific to this domain.


External integrations

Integration Endpoint Used by Failure / fallback
mr-hire-backend — batch match scoring POST {MR_HIRE_BACKEND_URL}/api/v1/screening/score-match-batch (timeout 30s) runMatching Stage 2 On error or invalid response, falls back to local simpleMatchScore JS heuristic.
mr-hire-backend — quiz generation POST {MR_HIRE_BACKEND_URL}/api/v1/quiz/generate (timeout 60s, forwards caller Bearer token) inviteMatch On failure, candidate is invited without a quiz; no email is sent.
mr-hire-backend — salary estimate POST {MR_HIRE_BACKEND_URL}/api/v1/salary-benchmarks/estimate (timeout 120s, all roles in one call) refreshBenchmarks On failure or missing benchmarks, returns empty list and nothing is upserted (existing rows untouched).
PostgreSQL jsonb parsedSkills::jsonb ?| ARRAY[...] overlap operator runMatching Stage 1 If the query throws, falls back to fetching the user's entries and filtering skills in JS.
Redis / BullMQ salaryBenchmarkQueue refresh schedule Standard BullMQ retry/removal config.

Env vars

  • MR_HIRE_BACKEND_URL — base URL of the Python AI backend; defaults to http://localhost:8001 in both TalentPoolController and SalaryBenchmarkService.
  • REDIS_HOST / REDIS_PORT — BullMQ connection for the worker (default localhost:6379).
  • JWT_SECRET — required by authMiddleware.

No dedicated feature flags gate these domains; behavior degrades to local heuristics when the AI backend is down.


Status lifecycles

TalentPoolMatch

stateDiagram-v2
  [*] --> suggested : runMatching scores 30 or above
  suggested --> invited : inviteMatch creates application
  suggested --> dismissed : dismissMatch
  suggested --> [*] : re-run matching deletes old suggested
  invited --> [*]
  dismissed --> [*]

Only suggested matches can be invited or dismissed (the controller rejects others with 400 Match already ...). Re-running matching deletes the prior suggested rows for that job but leaves invited and dismissed intact.

TalentPoolEntry (origin / reason)

stateDiagram-v2
  [*] --> not_shortlisted : screening score between thresholds
  [*] --> auto_rejected : screening score low but above 20
  [*] --> manual_add : recruiter adds candidate
  not_shortlisted --> [*] : removeFromPool
  auto_rejected --> [*] : removeFromPool
  manual_add --> [*] : removeFromPool

reason is set at creation and not transitioned afterward; entries are otherwise mutable only in tags/notes.

SalaryBenchmark

stateDiagram-v2
  [*] --> active : upsert sets isActive true
  active --> active : refresh upsert updates range
  active --> inactive : isActive set false
  inactive --> [*]

Only isActive rows are listed/served. The refresh job always upserts with isActive: true; there is no code path in this domain that sets isActive: false (it would be a manual/data operation).


Edge cases, limits & gotchas

  • Ownership not re-checked on mutation. removeFromPool, updateTags, dismissMatch, and inviteMatch look up rows purely by id and do not verify the row belongs to the caller's belongsTo. Any authenticated user who knows an id can act on another recruiter's entry/match. (inferred risk — the listing endpoint is scoped, but the mutators are not.)
  • Two belongsTo semantics. Auto-curated entries store the job post owner as belongsTo; manually-added entries store the caller. The listing query handles the null-owner case via the job-post fallback, but a mismatch between "who added" and "who owns the job" can make an entry visible to a different recruiter than the one who created it.
  • Matching is owner-scoped to non-null belongsTo. Stage 1 filters tp."belongsTo" = :userId. Entries with a NULL owner (auto-curated when the job post had no resolvable owner) are excluded from matching for any user.
  • Score threshold 30 is hardcoded in runMatching; the auto-curation thresholds come from the screening worker config (autoRejectThreshold / autoShortlistThreshold) and the literal > 20 floor for auto_rejected.
  • Unique applicationId. Re-adding the same application returns 409; the auto-curation worker also short- circuits if an entry already exists, so a candidate is never double-pooled.
  • Invite copies, does not move. Inviting creates a new JobApplication (source talent_pool) plus a new ScreeningResult that copies resume scores but starts a fresh quiz. The original application/screening are untouched, and the pool entry remains in place — only the match flips to invited.
  • Manual refresh is synchronous and slow. POST /api/admin/salary-benchmarks/refresh blocks the HTTP request for up to ~120s (the LLM timeout) plus DB upserts; prefer the scheduled queue path for production refreshes.
  • getAll heuristic filtering can hide legitimate benchmarks whose role name does not keyword/word-overlap any active job-post role, and relies on a hardcoded excludedCompanyNames list to suppress polluted rows — both are maintenance hazards if role naming drifts.
  • No multi-platform (x-platform) branching in this domain; entries carry the platform only indirectly via the copied JobApplication.platform on invite. Pool scoping is by belongsTo, not by tenant.
  • availability and sourceId are effectively unused by current code paths (declared on the entities but not written by any controller/service here). (inferred)
  • Experience units mismatch trap. parsedExperience is in months; JobPost.experienceRequired is in years. simpleMatchScore correctly multiplies required years by 12 — keep this in mind for any new scorer.