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, producingTalentPoolMatchrows that a recruiter can act on. - Inviting a matched candidate spawns a brand-new
JobApplicationon the target job (sourcetalent_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
scoreand areasonfor 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), ormanual_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-readablematchReason, and astatus(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/industryMaxin a currency (default INR), plus optionalmarketDatapercentiles and LLMreasoning. - 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.applicationIdis unique (@Index(['applicationId'], { unique: true })) — one pool entry per application. Adding the same application twice returns409.TalentPoolEntry.parsedSkillsisjsonb(used for the PostgreSQL?|array-overlap filter);parsedExperienceis stored in months.SalaryBenchmarkhas a unique composite index on(role, experienceYears)—upsertkeys off this pair.- Foreign keys:
TalentPoolEntry.applicationisonDelete: CASCADE;TalentPoolEntry.jobPostisonDelete: SET NULL(nullable);TalentPoolMatchcascades 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 tollm(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:
adminMiddlewareallows onlyUserRole.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 viabelongsToscoping.
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 tohttp://localhost:8001in bothTalentPoolControllerandSalaryBenchmarkService.REDIS_HOST/REDIS_PORT— BullMQ connection for the worker (defaultlocalhost:6379).JWT_SECRET— required byauthMiddleware.
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, andinviteMatchlook up rows purely by id and do not verify the row belongs to the caller'sbelongsTo. 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
belongsTosemantics. Auto-curated entries store the job post owner asbelongsTo; 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 filterstp."belongsTo" = :userId. Entries with aNULLowner (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> 20floor forauto_rejected. - Unique
applicationId. Re-adding the same application returns409; 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(sourcetalent_pool) plus a newScreeningResultthat 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 toinvited. - Manual refresh is synchronous and slow.
POST /api/admin/salary-benchmarks/refreshblocks the HTTP request for up to ~120s (the LLM timeout) plus DB upserts; prefer the scheduled queue path for production refreshes. getAllheuristic filtering can hide legitimate benchmarks whose role name does not keyword/word-overlap any active job-post role, and relies on a hardcodedexcludedCompanyNameslist 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 copiedJobApplication.platformon invite. Pool scoping is bybelongsTo, not by tenant. availabilityandsourceIdare effectively unused by current code paths (declared on the entities but not written by any controller/service here). (inferred)- Experience units mismatch trap.
parsedExperienceis in months;JobPost.experienceRequiredis in years.simpleMatchScorecorrectly multiplies required years by 12 — keep this in mind for any new scorer.
Related docs¶
- Mr. Hire — Jobs & Applications — job posts, applications, screening, candidate quiz, and the pipeline that feeds and consumes the talent pool. (sibling doc; may be written separately)
- Token economy
- Mentor earnings & payouts