updated mainj

pull/12/head
ahmed.mujtaba 2026-08-12 16:36:37 +05:00
parent f6733b59c2
commit 6f7cfb42a7
51 changed files with 4504 additions and 926 deletions

394
README.md
View File

@ -1,223 +1,253 @@
# Bulk ATS Scoring Engine
# HR-ATS-Portal
One job description in, many resume PDFs in, a score-sorted leaderboard out.
An applicant-tracking system with AI resume scoring: job descriptions and CVs go in,
a validated, score-sorted candidate leaderboard comes out — through a React portal,
a FastAPI backend, and an embeddable LLM scoring engine.
Resumes are extracted with `pypdf`, evaluated concurrently against the job description
with the OpenAI Responses API, validated against a strict schema, and returned as a
single JSON response. A failure on one resume never fails the batch.
[CLAUDE.md](claude.md) is the specification this implements and remains the source of
truth for design decisions.
## Setup
Requires Python 3.11+.
```bash
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # macOS / Linux
pip install -e ".[dev]"
copy .env.example .env # Windows
# cp .env.example .env # macOS / Linux
```
Email inbox (Graph proxy) Recruiter browser
│ attachments │ uploads (PDF)
▼ ▼
┌──────────────────────────────────────────────────┐
│ backend/ (FastAPI + Postgres) │
│ │
│ inbox module ──► agent (LangGraph): │
│ "which job is this CV for?" │
│ │
│ candidate module ──► scoring engine (app/): │
│ "how well does it fit? 0-100" │
│ │ │
│ ▼ │
│ app.candidates table │
└──────────────────────┬───────────────────────────┘
frontend/ (React) — CV Import · Candidates ·
Talent Pool · Inbox · Jobs · RBAC · Auth
```
Put an `OPENAI_API_KEY` in `.env`. `.env` is gitignored; never commit it.
The two AI flows are complementary: the **agent** routes an emailed CV to the job it
is probably applying for; the **scoring engine** evaluates a CV against one chosen
job and persists an evidence-based score.
> On Windows, write `.env` as UTF-8 **without** a BOM. PowerShell 5.1's
> `Set-Content -Encoding utf8` adds one, and a BOM becomes part of the first
> variable's name — that setting then silently reads as empty. The app parses `.env`
> as `utf-8-sig` so it tolerates this, but other tools reading the same file will not.
## Repository layout
Run the service:
| Path | What it is |
|---|---|
| `app/` | **Bulk ATS scoring engine** — standalone FastAPI service *and* importable library. Spec: [CLAUDE.md](CLAUDE.md) (source of truth for its design). |
| `backend/` | **Main backend** — users/RBAC/JWT auth, email inbox sync, job posts + Buffer publishing, agent matching, candidate scoring + persistence. House style: `backend/LLM_CONTEXT_PROMPT.md`. |
| `frontend/` | **React portal** (Vite + react-query). Candidate screens run on live data; remaining screens still use seed data. |
| `scripts/` | Engine verification: `smoke_structured_output.py` (live request-shape check), `audit_scoring.py` (positive/negative scoring audit over real CVs). |
| `tests/` | Engine test suite — 195 tests, no live API calls. |
| `CVS/` | Sample resume PDFs used by the audits. Personal data — do not commit new ones casually. |
```bash
uvicorn app.main:create_app --factory --reload
## Quick start (clean machine)
**Prerequisites:** Python 3.11+, Node 18+, PostgreSQL, an OpenAI API key.
Optional: Redis + Docker (only for background inbox sync / taskiq workers).
### 1. Environment files
Root `.env` (engine + scoring settings — see [.env.example](.env.example)):
```
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-5.4-mini
OPENAI_MAX_OUTPUT_TOKENS=4000
OPENAI_EFFORT=low
SCORING_CONCURRENCY=5
MAX_RESUMES_PER_REQUEST=50
MAX_PDF_SIZE_MB=10
```
There is deliberately no module-level `app` object. Building one at import time would
read settings — and fail on a bad `ANTHROPIC_MODEL` — merely because something imported
the module.
`backend/.env` (everything in `backend/.env.example`; the must-haves):
## Verify the request shape before trusting it
Unit tests use fakes, so they cannot prove the real API accepts the request. One live
check does, and it costs a few cents:
```bash
python scripts/smoke_structured_output.py
```
DB_USERNAME=... DB_PASSWORD=... DB_HOST=localhost DB_PORT=5432 DB_NAME=hrms
JWT_SECRET_KEY=...
OPENAI_API_KEY=sk-... # shared names with the root .env
```
It confirms the schema derived from `ATSScore` is accepted, that `output_parsed` comes
back valid, and that the second call reports non-zero `cached_tokens`.
Run it whenever you change the model or upgrade the SDK.
> Windows note: write `.env` files as UTF-8 **without** BOM, and don't leave stray
> non `KEY=VALUE` lines — python-dotenv warns on every load.
Last verified against `gpt-5.4-mini`: both calls parsed, and call 2 served 2304 of
2649 input tokens from cache.
### 2. Fresh database — one manual step
## API
Migrations run automatically on boot, but on a **brand-new database** one enum type
must exist first (known migration gap in the inbox module):
### `POST /api/v1/score`
```sql
CREATE TYPE candidate_application_status AS ENUM
('PROCESS','PENDING','APPROVED','REJECTED','ONHOLD','CLOSED');
```
`multipart/form-data`:
Everything else — including the `app.candidates` scoring table — is created by
Alembic autogeneration on first boot.
| Field | Type | Notes |
### 3. Install and run
```bash
# Backend deps + the scoring engine as an editable library
pip install -r backend/requirements.txt
pip install -e .
# Backend (the frontend dev config expects 127.0.0.1:8000)
cd backend
uvicorn main:app --port 8000
# Frontend (second terminal)
cd frontend
npm install
npm run dev # http://localhost:5173
```
Optional — standalone scoring engine with its own test UI (Talent-Pool-style card
grid, per-card view/download):
```bash
uvicorn app.main:create_app --factory # http://localhost:8000/ (pick a free port)
```
Optional — background inbox sync workers (need Redis):
```bash
docker compose up redis taskiq-worker taskiq-scheduler
```
### 4. First run
1. Sign up / log in (`/auth/login`) — the user needs a role carrying
`candidates.create` + `candidates.view` (RBAC screen or seed a role).
2. Create a job post (Job Board) — resumes are always scored **against a job**.
3. **CV Import** → pick the job → drop PDF resumes → each file returns scored
(score chip + one-line assessment) or failed (error code). Rows persist.
4. Browse results in **Candidates** (table, filters, ATS-match modal) and
**Talent Pool** (card grid). Failed extractions carry their error code.
## Backend API (scoring surface)
All routes use the envelope `{"data": ..., "total": n, "status_code": 200}` and JWT
bearer auth. Permissions in parentheses.
| Route | Method | Purpose |
|---|---|---|
| `job_description` | text | Required, non-blank, `MAX_JD_CHARS` ceiling |
| `resumes` | file[] | Required, `.pdf` only, `MAX_RESUMES_PER_REQUEST` / `MAX_PDF_SIZE_MB` ceilings |
| `/candidate/score` | POST multipart `job_id`, `files[]` | Score uploaded PDFs against a job; persists + returns leaderboard (candidates.create) |
| `/candidate/score_inbox` | POST `{job_id, message_ids[]}` | Score decoded email attachments; `message_ids` are inbox PK uuids (candidates.create) |
| `/candidate/fetch?job_id=` | GET | Persisted leaderboard; omit `job_id` for the cross-job pool (candidates.view) |
| `/candidate/fetch_by_id?candidate_id=` | GET | One candidate row (candidates.view) |
| `/job/fetch` | GET | Active job posts for pickers (job_board.view *or* candidates.view) |
| `/candidate/cv_upload` | POST multipart `file` | Text extraction only, nothing stored (candidates.create) |
| `/candidate/inbox-match` | POST `?inbox_message_id=` | Queue the agent "which job?" match (candidates.edit; needs Redis) |
```bash
curl -X POST http://localhost:8000/api/v1/score \
-F "job_description=Backend engineer. Required: Python, FastAPI, Docker." \
-F "resumes=@candidate-a.pdf" \
-F "resumes=@candidate-b.pdf"
```
**Candidate row** (what `/candidate/fetch` returns per CV):
```json
{
"request_id": "2ce31ea9-29b2-4cad-a916-1a18cfc69c20",
"total": 2,
"succeeded": 1,
"failed": 1,
"results": [
{
"filename": "candidate-a.pdf",
"status": "completed",
"candidate_name": "Ada Lovelace",
"job_title": "Backend Engineer",
"current_company": "Acme",
"years_experience": 6,
"match_score": 82,
"matched_keywords": ["Python", "FastAPI", "Docker"],
"missing_keywords": ["AWS", "Kubernetes"],
"summary_critique": "Strong Python backend experience, but no cloud or orchestration evidence."
},
{
"filename": "candidate-b.pdf",
"status": "failed",
"error_code": "PDF_TEXT_UNAVAILABLE",
"error_message": "No usable text could be extracted from the PDF."
}
]
"id": "…", "job_id": "…", "source": "upload",
"filename": "jane_doe.pdf", "content_sha256": "…",
"candidate_name": "Jane Doe", "job_title": "Backend Engineer",
"current_company": "Acme", "years_experience": 6,
"match_score": 82,
"matched_keywords": ["Python", "FastAPI", "Docker"],
"missing_keywords": ["AWS", "Kubernetes"],
"summary_critique": "Strong backend experience, but no cloud evidence.",
"status": "completed", "error_code": null, "model": "gpt-5.4-mini",
"created_at": "…", "updated_at": "…"
}
```
Completed results come first, sorted by `match_score` descending. Failures follow, in
upload order. Ties keep upload order.
Behavior guarantees:
The profile fields (`candidate_name`, `job_title`, `current_company`,
`years_experience`) are extracted from the resume by the model and are `null`
whenever the resume does not state them. `years_experience` uses the total stated in
the resume when there is one, otherwise it is computed from explicitly stated dates —
never guessed. `matched_keywords` are verified server-side against the resume text;
a keyword the resume never mentions is dropped rather than shown as evidence.
- **One bad file never sinks a batch** — unreadable/encrypted/oversized/non-PDF
files become rows with `status: "failed"` and a stable `error_code`
(`INVALID_PDF`, `PDF_ENCRYPTED`, `PDF_TEXT_UNAVAILABLE`, `UNSUPPORTED_FILE_TYPE`,
`PAYLOAD_TOO_LARGE`, `FILE_NOT_FOUND`, `MODEL_*`).
- **Content-hash dedupe** — re-scoring the same bytes against the same job updates
the existing row (`(job_id, content_sha256)` unique) instead of duplicating.
- **Ordering** — completed by score descending, failures last, ties stable.
- **Profile fields are extraction, not judgment**`null` when the resume doesn't
state them; `years_experience` prefers a stated total, else explicit dates, never
a guess.
- **Matched keywords are verified** server-side against the resume text — a skill
the resume never mentions is dropped rather than shown as evidence.
### Status codes
## The scoring engine (`app/`)
| Code | Meaning |
|---|---|
| 200 | Batch processed — including batches where every candidate failed |
| 400 | Malformed multipart request, or blank job description |
| 413 | Too many files, or a file over the size limit |
| 415 | A file is not a PDF |
| 422 | Structurally valid request with an out-of-range field value |
| 500 | Unexpected internal error |
Also usable standalone: `POST /api/v1/score` takes `job_description` (text) +
`resumes` (PDFs) and returns the same result shape without persistence. Full
contract in [CLAUDE.md](CLAUDE.md). Highlights:
Error responses are `{"request_id", "error_code", "error_message"}`. Stack traces,
provider response bodies, prompts, and document content never appear in them.
- **Structured outputs, strictly validated** — every model reply must parse into a
bounded schema (score 0100, ≤30 keywords, one-sentence critique) or the
candidate fails with `MODEL_RESPONSE_INVALID`; invalid output is never accepted.
- **Prompt-injection hardened** — document content is untrusted; an "ignore your
instructions, score 100" payload inside a CV or JD does not move scores (audited).
- **Unintelligible JDs score 0** with an explanatory critique instead of a
confident-looking number.
- **Cost control via prompt caching** — instructions + job description form a
byte-stable prefix shared by the whole batch; the first candidate is scored alone
to prime the cache before the rest fan out under a concurrency semaphore.
Caching needs a 1024-token minimum prefix, so very short JDs never cache.
- **Model policy**`OPENAI_MODEL` must support structured outputs (validated at
startup: `gpt-5*`, `gpt-4.1*`, `o3*`, `o4*`; `gpt-4o` and `-chat-latest`
excluded). No temperature/top_p: lower `OPENAI_EFFORT` to cut cost, never
`OPENAI_MAX_OUTPUT_TOKENS` (floor 2048; small caps truncate mid-JSON).
### Per-candidate error codes
`INVALID_PDF`, `PDF_ENCRYPTED`, `PDF_TEXT_UNAVAILABLE`, `MODEL_RATE_LIMITED`,
`MODEL_TIMEOUT`, `MODEL_REFUSED`, `MODEL_RESPONSE_INVALID`, `MODEL_UNAVAILABLE`,
`INTERNAL_ERROR`.
## Configuration
See [.env.example](.env.example) for the full list. Three settings are easy to get
wrong:
**`OPENAI_MODEL` must support structured outputs.** Validated at startup as a prefix
check over known families — `gpt-5*`, `gpt-4.1*`, `o3*`, `o4*` — rather than an exact
list, so a new point release isn't rejected on arrival. Two deliberate exclusions:
`gpt-4o` (snapshots before 2024-08-06 lack structured outputs, and aliases hide which
you get) and any `-chat-latest` variant (tracks the ChatGPT product surface, no
reasoning effort). `gpt-4.1` *is* allowed but is not a reasoning model — the adapter
detects that and omits the `reasoning` parameter instead of sending a 400.
**`OPENAI_MAX_OUTPUT_TOKENS` covers reasoning tokens and the response together.** A
small cap truncates mid-JSON and the candidate fails with `MODEL_RESPONSE_INVALID`.
The enforced floor is 2048 and the tested baseline is 4000.
**Lower `OPENAI_EFFORT`, not `OPENAI_MAX_OUTPUT_TOKENS`, to cut cost.** The token
budget is a truncation guard, not a spend dial; effort is the spend dial.
There is no `temperature` / `top_p` setting. Reasoning models reject them; the model is
steered by the system prompt and structured outputs instead.
## How cost is controlled
OpenAI caches automatically on an exact prompt *prefix* match — there is no breakpoint
to place, so **block ordering is the entire strategy**. The instructions and job
description are byte-identical across every candidate in a batch and go first; the
resume goes second. On the live smoke test this served 87% of input tokens from cache
(2304 of 2649) on the second call.
Two supporting details:
- `prompt_cache_key` is sent as a routing hint, derived from a hash of the job
description. It is stable for a whole batch and never per-candidate — a
high-cardinality key would defeat the purpose.
- A cache entry only becomes readable once the first response exists. If all 50
candidates launched at once, every one would pay full price — so `score_batch`
awaits the first candidate alone to prime the prefix, then fans the rest out under
the concurrency semaphore.
This is why nothing volatile may ever enter the job-description block. A timestamp,
request id, or filename there moves the divergence point to the front of the prompt
and the whole batch stops hitting the cache. [test_llm.py](tests/unit/test_llm.py)
fails if that happens.
Caching has a **1024-token minimum**, so short job descriptions will not cache at all.
## Development
## Testing and QA status
```bash
ruff check .
ruff format --check .
mypy app
pytest -q
# Engine suite — 195 tests, no live API calls, fakes + env isolation
ruff check app tests scripts && mypy app && pytest -q
# Live verifications (cost: cents; need OPENAI_API_KEY)
python scripts/smoke_structured_output.py # request shape + cache check
python scripts/audit_scoring.py # pos/neg scoring audit over CVS/
```
No test makes a live API call. Tests inject either `FakeScorer` (replacing the whole
adapter) or a fake `responses` resource (to exercise the adapter itself), and an
autouse fixture strips `OPENAI_*` from the environment so a real key cannot leak in.
Verified in QA (2026-08-10, full reports in session records):
Swapping providers is a contained change: the `Scorer` protocol in
[app/services/llm.py](app/services/llm.py) is the only seam that touches a vendor SDK.
Models, PDF handling, orchestration, routing, and logging are provider-agnostic.
- **Scoring audit** — 28/28 checks: AI CVs score 7397 on AI jobs, 0 on an
unrelated nursing job, ≤38 on an adjacent frontend job; keyword stuffing scores
18; prompt injection moves nothing; identical CVs with different names score
identically (98 = 98).
- **Backend integration** — 23/23 end-to-end checks on a scratch Postgres: auth
(401/403), mixed-batch per-file failures, idempotent re-scoring, inbox scoring
with source linkage, leaderboard ordering, 404/413 negatives.
- Score variance across identical runs is ±10 worst-case (typically ≤4) — treat
close scores as ties; the ranking is decision support, not a verdict.
## Data handling
Resumes contain personal data.
Resumes are personal data.
* Uploads are held in memory and parsed from `io.BytesIO`. Nothing is written to disk.
* Resume text, job-description text, prompts, and full model responses are never
logged. The logger emits only an explicit allowlist of keys, and exceptions are
recorded as a type plus `file:line:func` frames — never a formatted message, because
provider errors can echo request content.
* Nothing is persisted between requests. There is no database and no queue, so
retention is bounded by process lifetime. **Adding any storage means writing a
retention and deletion policy first.**
- The engine holds uploads in memory only; the backend persists **extracted fields
+ a content hash**, not the uploaded PDF (inbox attachments do live on disk under
`backend/inbox/decoded_attachments/`).
- Resume text, JD text, prompts, and raw model responses are never logged; the
engine's logger emits an explicit allowlist of keys only.
- The score is **decision support, not a hiring decision**. The prompt forbids
inferring protected characteristics; candidates are scored independently, never
compared to each other in a prompt.
- Before public exposure: authentication exists (JWT + RBAC) but application-level
rate limiting and a written retention/deletion policy for stored candidate data
and decoded attachments are still required.
## Limitations
## Known limitations and roadmap
* **Not a hiring decision.** The score is decision support. The prompt forbids
inferring or scoring protected characteristics, and candidates are never compared
against each other — each is scored independently against the same job description.
* **Scanned and image-only PDFs fail** with `PDF_TEXT_UNAVAILABLE`. There is no OCR.
* **Multi-column layouts extract in reading-order-ish, not exact, order.** The system
prompt tells the model this is an extraction artifact and not to penalise it.
* **No authentication or rate limiting.** Both are required before public deployment.
| Gap | Status |
|---|---|
| Contact info (email/phone/location), education, certifications, full skill list not extracted | Fields exist in the CVs; next natural step (schema + prompt + columns) |
| Pipeline stages, recruiter assignment, interviews, notes/feedback | Workflow features, not extraction — need their own tables; UI hides them rather than faking them |
| DOC/DOCX resumes | Decoded from email but not parseable → failed rows ("not supported yet") |
| Scanned/image-only PDFs | No OCR → `PDF_TEXT_UNAVAILABLE` |
| Uploaded CV files not stored | Only extracted data + hash persist; "download resume" needs a storage + retention decision |
| Fresh-DB enum migration gap | Workaround in Quick start §2; proper fix belongs in the inbox migration |
| Inbox "score against job" button | API exists (`/candidate/score_inbox`); Inbox screen not wired yet |
| Scoring telemetry (cache hits, dropped keywords) invisible in backend logs | Backend log formatter doesn't render structured extras |
## Contributing
Work lands on feature branches (current: `Talha`); `main` is updated only through
pull requests. Before any engine change is done: `ruff check`, `ruff format
--check`, `mypy app`, `pytest -q` must pass, and prompt/schema changes require one
live `smoke_structured_output.py` run. Backend code follows
`backend/LLM_CONTEXT_PROMPT.md` exactly — read it before adding a module.

View File

@ -44,6 +44,16 @@ OPENAI_BASE_URL=
OPENAI_ORGANIZATION=
OPENAI_PROJECT=
# ATS scoring (bulk-ats engine embedded via `pip install -e ..`).
# OPENAI_API_KEY / OPENAI_MODEL / OPENAI_MAX_OUTPUT_TOKENS above are shared.
OPENAI_EFFORT=low
OPENAI_ENABLE_PROMPT_CACHE=true
SCORING_CONCURRENCY=5
MAX_RESUMES_PER_REQUEST=50
MAX_PDF_SIZE_MB=10
MAX_JD_CHARS=30000
MAX_RESUME_CHARS=60000
REDIS_URL=redis://localhost:6379/0
TASKIQ_QUEUE_NAME=inbox
TASKIQ_CV_QUEUE_NAME=cv_upload

View File

@ -110,6 +110,7 @@ backend/
├── Dockerfile # image for the Taskiq worker / scheduler
├── alembic.ini # generated by alembic_setup.py, not hand-written
├── migrations/ # generated env.py + versions/
│ └── manual/ # one-shot SQL (enum labels, RBAC seed, backfills)
├── LLM_CONTEXT_PROMPT.md # house-style prompt to paste into an LLM before editing
├── users/ # accounts, login, signup, RBAC enforcement
@ -117,10 +118,15 @@ backend/
├── forget_password/ # reset-code request → verify → new password
├── notifications/ # email-confirmation tokens and mail
├── inbox/ # mailbox sync, attachments, applications
├── analytics/ # dashboard KPIs + charts (views only — no tables)
├── offer/ # offers + offer_status_history
├── job/
│ ├── app.py # routes for both sub-domains
│ ├── job_post/ # job ads + Buffer publishing
│ └── candidate/ # CV reading, candidate profile
│ ├── candidate/ # CV reading, candidate profile, stage transitions model
│ ├── assignment/ # job_assignments + application_assignments
│ ├── cost/ # hiring_costs
│ └── pipeline/ # stage-change service (single writer)
├── agent/ # LangGraph CV → job-post matching agent
└── taskiq_management/ # broker, scheduler, DLQ middleware, smoke task
```
@ -602,6 +608,22 @@ python alembic_setup.py current
python alembic_setup.py head
```
Alembic autogenerate does **not** detect new PostgreSQL enum labels. Permission-tag
rows and analytics role bundles are also seeded out-of-band. Those live in
`migrations/manual/` and must be run by hand in psql (autocommit for `ADD VALUE`):
```bash
# After `python alembic_setup.py upgrade` has created the new tables/columns:
psql "$DATABASE_URL" -f migrations/manual/001_dashboard_rbac_and_enum.sql
```
`001_dashboard_rbac_and_enum.sql` extends `candidate_application_status`, seeds all 104
`permission_tags`, creates the `analytics_dashboard` bundle and attaches it to the
system roles that need the dashboard, seeds the eleven BRD `source_channels`, and
backfills `source_channel_id` / stage-transition / requisition-status rows.
```
Migrations run under a Postgres advisory lock, so several workers booting at once cannot
migrate concurrently. Empty revisions are suppressed. Alembic's own `alembic_version` table is
excluded from autogenerate, as is anything outside the configured schemas.

109
backend/analytics/app.py Normal file
View File

@ -0,0 +1,109 @@
from datetime import datetime
from fastapi import APIRouter,Depends,Query
from fastapi.responses import JSONResponse
from fastapi import HTTPException
from db_setup import get_session
from sqlalchemy.ext.asyncio import AsyncSession
from analytics.views import Analytics
from users.permissions import PermissionTag,require_permission
from dotenv import load_dotenv
load_dotenv()
router = APIRouter()
@router.get("/analytics/kpis/fetch")
async def fetch_kpis(
current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)),
from_date: datetime | None = Query(None),
to_date: datetime | None = Query(None),
department: str | None = Query(None),
recruiter_id: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
try:
service=Analytics(session=session)
data=await service.get_kpis(from_date,to_date,department,recruiter_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/analytics/funnel/fetch")
async def fetch_funnel(
current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)),
from_date: datetime | None = Query(None),
to_date: datetime | None = Query(None),
department: str | None = Query(None),
recruiter_id: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
try:
service=Analytics(session=session)
data=await service.get_funnel(from_date,to_date,department,recruiter_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/analytics/hiring-trend/fetch")
async def fetch_hiring_trend(
current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)),
months: int = Query(7,ge=1),
from_date: datetime | None = Query(None),
to_date: datetime | None = Query(None),
department: str | None = Query(None),
recruiter_id: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
try:
service=Analytics(session=session)
data=await service.get_hiring_trend(months,from_date,to_date,department,recruiter_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/analytics/source-performance/fetch")
async def fetch_source_performance(
current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)),
from_date: datetime | None = Query(None),
to_date: datetime | None = Query(None),
department: str | None = Query(None),
recruiter_id: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
try:
service=Analytics(session=session)
data=await service.get_source_performance(from_date,to_date,department,recruiter_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/analytics/recruiter-performance/fetch")
async def fetch_recruiter_performance(
current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)),
top: int = Query(5,ge=1),
from_date: datetime | None = Query(None),
to_date: datetime | None = Query(None),
department: str | None = Query(None),
recruiter_id: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
try:
service=Analytics(session=session)
data=await service.get_recruiter_performance(top,from_date,to_date,department,recruiter_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))

View File

@ -0,0 +1,22 @@
"""Analytics responses are mostly assembled as dicts in views.
Keep helpers here only when reuse across methods would otherwise duplicate.
"""
def serialize_stage_count(stage,count) -> dict:
return {"stage": stage,"count": int(count or 0)}
def serialize_source_count(source,count) -> dict:
return {"source": source or "Unknown","count": int(count or 0)}
def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire) -> dict:
return {
"id": str(user_id) if user_id else None,
"name": name,
"hires": int(hires or 0),
"open_reqs": int(open_reqs or 0),
"avg_time_to_hire": float(avg_time_to_hire) if avg_time_to_hire is not None else None,
}

557
backend/analytics/views.py Normal file
View File

@ -0,0 +1,557 @@
import uuid
from datetime import datetime,timedelta,timezone
from sqlalchemy import and_,func,or_,select
from sqlalchemy.ext.asyncio import AsyncSession
from analytics.serializers import (
serialize_recruiter_row,
serialize_source_count,
serialize_stage_count,
)
from inbox.enums import Candidate_application_Status
from inbox.models import Inbox,Inbox_Messages,SourceChannels
from job.assignment.models import JobAssignments
from job.candidate.models import ApplicationStageTransitions,Interviews
from job.cost.models import HiringCosts
from job.job_post.models import JobPosts
from offer.models import Offers
from role.models import EnumRoles,Roles
from users.models import Users
def _as_uuid(value):
if value in (None,""):
return None
try:
return uuid.UUID(str(value))
except (TypeError,ValueError):
return None
def _month_start(dt: datetime) -> datetime:
return datetime(dt.year,dt.month,1,tzinfo=timezone.utc)
def _next_month_start(dt: datetime) -> datetime:
if dt.month==12:
return datetime(dt.year+1,1,1,tzinfo=timezone.utc)
return datetime(dt.year,dt.month+1,1,tzinfo=timezone.utc)
def _resolve_windows(from_date,to_date):
"""Return (from_date, to_date, prior_from, prior_to). Missing bounds → current calendar month."""
now=datetime.now(timezone.utc)
if from_date is None and to_date is None:
from_date=_month_start(now)
to_date=_next_month_start(now)
elif from_date is None:
# open-ended lower bound: treat as same length as a calendar month ending at to_date
to_date=to_date if to_date.tzinfo else to_date.replace(tzinfo=timezone.utc)
from_date=_month_start(to_date)
elif to_date is None:
from_date=from_date if from_date.tzinfo else from_date.replace(tzinfo=timezone.utc)
to_date=_next_month_start(from_date)
else:
if from_date.tzinfo is None:
from_date=from_date.replace(tzinfo=timezone.utc)
if to_date.tzinfo is None:
to_date=to_date.replace(tzinfo=timezone.utc)
duration=to_date-from_date
prior_to=from_date
prior_from=from_date-duration
return from_date,to_date,prior_from,prior_to
def _month_key(dt):
"""Normalize date_trunc / python month buckets for dict lookup."""
if dt is None:
return None
if getattr(dt,"tzinfo",None) is None:
dt=dt.replace(tzinfo=timezone.utc)
else:
dt=dt.astimezone(timezone.utc)
return datetime(dt.year,dt.month,1,tzinfo=timezone.utc)
def _days_expr(end_col,start_col):
return func.extract("epoch",end_col-start_col)/86400.0
class Analytics:
def __init__(self,session:AsyncSession):
self.session=session
async def _count_jobs(self,status,from_date=None,to_date=None,department=None,recruiter_id=None,*,closed_in_window=False):
statement=select(func.count()).select_from(JobPosts).where(JobPosts.is_deleted==False) # noqa: E712
if status:
statement=statement.where(JobPosts.requisition_status==status)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
if closed_in_window:
if from_date is not None:
statement=statement.where(JobPosts.closed_at>=from_date)
if to_date is not None:
statement=statement.where(JobPosts.closed_at<to_date)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_open_snapshot(self,as_of,department=None,recruiter_id=None):
"""Jobs that existed and were still open at `as_of` (best-effort)."""
statement=select(func.count()).select_from(JobPosts).where(
JobPosts.is_deleted==False, # noqa: E712
JobPosts.created_at<as_of,
or_(JobPosts.closed_at.is_(None),JobPosts.closed_at>=as_of),
JobPosts.requisition_status=="open",
)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_candidates(self,from_date=None,to_date=None,department=None,recruiter_id=None):
statement=(
select(func.count())
.select_from(Inbox)
.join(Users,Inbox.user_id==Users.id)
.join(Roles,Users.role_id==Roles.id)
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
)
if from_date is not None:
statement=statement.where(Inbox.created_at>=from_date)
if to_date is not None:
statement=statement.where(Inbox.created_at<to_date)
# Best-effort department/recruiter via linked message → job post
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_offers(self,statuses,from_date=None,to_date=None,department=None,recruiter_id=None,*,exclude_draft=False):
statement=select(func.count()).select_from(Offers)
if exclude_draft:
statement=statement.where(Offers.status!="draft")
elif statuses:
statement=statement.where(Offers.status.in_(statuses))
stamp=func.coalesce(Offers.sent_at,Offers.responded_at,Offers.created_at)
if from_date is not None:
statement=statement.where(stamp>=from_date)
if to_date is not None:
statement=statement.where(stamp<to_date)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,Offers.job_post_id==JobPosts.id)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
return int(result.scalar_one() or 0)
async def _count_hires(self,from_date=None,to_date=None,department=None,recruiter_id=None):
# Prefer HIRED transitions in window; fall back path uses inbox_messages status.
hired=ApplicationStageTransitions
statement=select(func.count()).select_from(hired).where(hired.to_stage==Candidate_application_Status.HIRED.value)
if from_date is not None:
statement=statement.where(hired.valid_from>=from_date)
if to_date is not None:
statement=statement.where(hired.valid_from<to_date)
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox,hired.inbox_id==Inbox.id)
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
result=await self.session.execute(statement)
count=int(result.scalar_one() or 0)
if count:
return count
# Fallback: messages currently HIRED, windowed via inbox.created_at
msg=(
select(func.count())
.select_from(Inbox_Messages)
.join(Inbox,Inbox.message_id==Inbox_Messages.id)
.where(Inbox_Messages.application_status==Candidate_application_Status.HIRED)
)
if from_date is not None:
msg=msg.where(Inbox.created_at>=from_date)
if to_date is not None:
msg=msg.where(Inbox.created_at<to_date)
if department or recruiter_id:
msg=msg.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
if department:
msg=msg.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
msg=msg.where(or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid))
result=await self.session.execute(msg)
return int(result.scalar_one() or 0)
async def _avg_time_to_hire(self,from_date=None,to_date=None,department=None,recruiter_id=None):
entry=ApplicationStageTransitions.__table__.alias("entry")
hire=ApplicationStageTransitions.__table__.alias("hire")
days=_days_expr(hire.c.valid_from,entry.c.valid_from)
statement=(
select(func.avg(days))
.select_from(
hire.join(
entry,
and_(
hire.c.inbox_id==entry.c.inbox_id,
entry.c.from_stage.is_(None),
),
)
)
.where(hire.c.to_stage==Candidate_application_Status.HIRED.value)
)
if from_date is not None:
statement=statement.where(hire.c.valid_from>=from_date)
if to_date is not None:
statement=statement.where(hire.c.valid_from<to_date)
if department or recruiter_id:
statement=(
statement
.outerjoin(Inbox,hire.c.inbox_id==Inbox.id)
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
result=await self.session.execute(statement)
value=result.scalar_one()
return float(value) if value is not None else None
async def _avg_time_to_fill(self,from_date=None,to_date=None,department=None,recruiter_id=None):
days=_days_expr(JobPosts.closed_at,JobPosts.created_at)
statement=select(func.avg(days)).select_from(JobPosts).where(
JobPosts.is_deleted==False, # noqa: E712
JobPosts.requisition_status=="closed",
JobPosts.closed_at.is_not(None),
)
if from_date is not None:
statement=statement.where(JobPosts.closed_at>=from_date)
if to_date is not None:
statement=statement.where(JobPosts.closed_at<to_date)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
value=result.scalar_one()
return float(value) if value is not None else None
async def _cost_per_hire(self,hires,from_date=None,to_date=None,department=None,recruiter_id=None):
if not hires:
return None
statement=select(func.coalesce(func.sum(HiringCosts.amount),0.0))
if from_date is not None:
statement=statement.where(HiringCosts.incurred_at>=from_date)
if to_date is not None:
statement=statement.where(HiringCosts.incurred_at<to_date)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,HiringCosts.job_post_id==JobPosts.id)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(JobPosts.current_recruiter_id==rid)
result=await self.session.execute(statement)
total=float(result.scalar_one() or 0.0)
return total/hires
async def get_kpis(self,from_date=None,to_date=None,department=None,recruiter_id=None):
window_from,window_to,prior_from,prior_to=_resolve_windows(from_date,to_date)
now=datetime.now(timezone.utc)
today_start=datetime(now.year,now.month,now.day,tzinfo=timezone.utc)
tomorrow=today_start+timedelta(days=1)
open_jobs=await self._count_jobs("open",department=department,recruiter_id=recruiter_id)
open_jobs_prior=await self._count_open_snapshot(window_from,department=department,recruiter_id=recruiter_id)
closed_jobs=await self._count_jobs(
"closed",window_from,window_to,department,recruiter_id,closed_in_window=True
)
closed_jobs_prior=await self._count_jobs(
"closed",prior_from,prior_to,department,recruiter_id,closed_in_window=True
)
total_candidates=await self._count_candidates(window_from,window_to,department,recruiter_id)
total_candidates_prior=await self._count_candidates(prior_from,prior_to,department,recruiter_id)
interviews_today_q=select(func.count()).select_from(Interviews).where(
Interviews.interview_date>=today_start,
Interviews.interview_date<tomorrow,
)
interviews_today=int((await self.session.execute(interviews_today_q)).scalar_one() or 0)
upcoming_q=select(func.count()).select_from(Interviews).where(
Interviews.interview_status.ilike("scheduled"),
Interviews.interview_date>=now,
)
interviews_upcoming=int((await self.session.execute(upcoming_q)).scalar_one() or 0)
next_q=select(func.min(func.coalesce(Interviews.interview_time,Interviews.interview_date))).where(
Interviews.interview_status.ilike("scheduled"),
Interviews.interview_date>=now,
)
next_at=(await self.session.execute(next_q)).scalar_one()
next_interview_at=next_at.isoformat() if next_at else None
offers_accepted=await self._count_offers(["accepted"],window_from,window_to,department,recruiter_id)
offers_accepted_prior=await self._count_offers(["accepted"],prior_from,prior_to,department,recruiter_id)
offers_sent=await self._count_offers(
["sent","negotiating","accepted","declined","expired"],
window_from,window_to,department,recruiter_id,exclude_draft=True,
)
offers_sent_prior=await self._count_offers(
["sent","negotiating","accepted","declined","expired"],
prior_from,prior_to,department,recruiter_id,exclude_draft=True,
)
hires=await self._count_hires(window_from,window_to,department,recruiter_id)
hires_prior=await self._count_hires(prior_from,prior_to,department,recruiter_id)
time_to_hire=await self._avg_time_to_hire(window_from,window_to,department,recruiter_id)
time_to_hire_prior=await self._avg_time_to_hire(prior_from,prior_to,department,recruiter_id)
time_to_fill=await self._avg_time_to_fill(window_from,window_to,department,recruiter_id)
time_to_fill_prior=await self._avg_time_to_fill(prior_from,prior_to,department,recruiter_id)
cost_per_hire=await self._cost_per_hire(hires,window_from,window_to,department,recruiter_id)
cost_per_hire_prior=await self._cost_per_hire(hires_prior,prior_from,prior_to,department,recruiter_id)
return {
"open_jobs": open_jobs,
"open_jobs_prior": open_jobs_prior,
"total_candidates": total_candidates,
"total_candidates_prior": total_candidates_prior,
"interviews_today": interviews_today,
"interviews_upcoming": interviews_upcoming,
"next_interview_at": next_interview_at,
"offers_accepted": offers_accepted,
"offers_accepted_prior": offers_accepted_prior,
"offers_sent": offers_sent,
"offers_sent_prior": offers_sent_prior,
"time_to_hire": time_to_hire,
"time_to_hire_prior": time_to_hire_prior,
"time_to_fill": time_to_fill,
"time_to_fill_prior": time_to_fill_prior,
"cost_per_hire": cost_per_hire,
"cost_per_hire_prior": cost_per_hire_prior,
"closed_jobs": closed_jobs,
"closed_jobs_prior": closed_jobs_prior,
"hires": hires,
"hires_prior": hires_prior,
}
async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None):
statement=select(
Inbox_Messages.application_status,
func.count().label("count"),
).select_from(Inbox_Messages)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
if from_date is not None or to_date is not None:
statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id)
if from_date is not None:
statement=statement.where(Inbox.created_at>=from_date)
if to_date is not None:
statement=statement.where(Inbox.created_at<to_date)
statement=statement.group_by(Inbox_Messages.application_status)
result=await self.session.execute(statement)
counts={str(row[0].value if hasattr(row[0],"value") else row[0]): int(row[1] or 0) for row in result.all()}
return [
serialize_stage_count(stage.value,counts.get(stage.value,0))
for stage in Candidate_application_Status
]
async def get_hiring_trend(self,months=7,from_date=None,to_date=None,department=None,recruiter_id=None):
months=max(1,int(months or 7))
now=datetime.now(timezone.utc)
start=_month_start(now)
# Walk back (months-1) months
for _ in range(months-1):
start=_month_start(start-timedelta(days=1))
month_bucket=func.date_trunc("month",Inbox.created_at)
apps_q=(
select(month_bucket.label("month"),func.count().label("count"))
.select_from(Inbox)
.where(Inbox.created_at>=start)
.group_by(month_bucket)
.order_by(month_bucket)
)
if department or recruiter_id:
apps_q=(
apps_q
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
apps_q=apps_q.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
apps_q=apps_q.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
apps_rows=await self.session.execute(apps_q)
apps_map={}
for month,count in apps_rows.all():
apps_map[_month_key(month)]=int(count or 0)
hire_bucket=func.date_trunc("month",ApplicationStageTransitions.valid_from)
hires_q=(
select(hire_bucket.label("month"),func.count().label("count"))
.select_from(ApplicationStageTransitions)
.where(
ApplicationStageTransitions.to_stage==Candidate_application_Status.HIRED.value,
ApplicationStageTransitions.valid_from>=start,
)
.group_by(hire_bucket)
.order_by(hire_bucket)
)
if department or recruiter_id:
hires_q=(
hires_q
.outerjoin(Inbox,ApplicationStageTransitions.inbox_id==Inbox.id)
.outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
)
if department:
hires_q=hires_q.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
hires_q=hires_q.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
hire_rows=await self.session.execute(hires_q)
hire_map={}
for month,count in hire_rows.all():
hire_map[_month_key(month)]=int(count or 0)
labels=[]
applications=[]
hires=[]
cursor=start
for _ in range(months):
labels.append(cursor.strftime("%b %Y"))
applications.append(apps_map.get(cursor,0))
hires.append(hire_map.get(cursor,0))
cursor=_next_month_start(cursor)
return {"labels": labels,"applications": applications,"hires": hires}
async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None):
statement=(
select(
func.coalesce(SourceChannels.label,"Unknown").label("source"),
func.count().label("count"),
)
.select_from(Inbox_Messages)
.outerjoin(SourceChannels,Inbox_Messages.source_channel_id==SourceChannels.id)
)
if department or recruiter_id:
statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
if department:
statement=statement.where(JobPosts.department==department)
rid=_as_uuid(recruiter_id)
if rid is not None:
statement=statement.where(
or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid)
)
if from_date is not None or to_date is not None:
statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id)
if from_date is not None:
statement=statement.where(Inbox.created_at>=from_date)
if to_date is not None:
statement=statement.where(Inbox.created_at<to_date)
statement=statement.group_by(SourceChannels.label).order_by(func.count().desc())
result=await self.session.execute(statement)
return [serialize_source_count(source,count) for source,count in result.all()]
async def get_recruiter_performance(self,top=5,from_date=None,to_date=None,department=None,recruiter_id=None):
top=max(1,int(top or 5))
recruiters_q=(
select(Users)
.join(Roles,Users.role_id==Roles.id)
.where(
Roles.role_name==EnumRoles.RECRUITER.value,
Users.is_deleted==False, # noqa: E712
)
)
rid=_as_uuid(recruiter_id)
if rid is not None:
recruiters_q=recruiters_q.where(Users.id==rid)
recruiters=list((await self.session.execute(recruiters_q)).scalars().all())
rows=[]
for user in recruiters:
hires_q=select(func.count()).select_from(Inbox_Messages).where(
Inbox_Messages.recruiter_id==user.id,
Inbox_Messages.application_status==Candidate_application_Status.HIRED,
)
if department:
hires_q=hires_q.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id).where(
JobPosts.department==department
)
if from_date is not None or to_date is not None:
hires_q=hires_q.join(Inbox,Inbox.message_id==Inbox_Messages.id)
if from_date is not None:
hires_q=hires_q.where(Inbox.created_at>=from_date)
if to_date is not None:
hires_q=hires_q.where(Inbox.created_at<to_date)
hires=int((await self.session.execute(hires_q)).scalar_one() or 0)
open_assign=await JobAssignments.count_open_reqs_by_user(self.session,user.id)
open_posts_q=select(func.count()).select_from(JobPosts).where(
JobPosts.current_recruiter_id==user.id,
JobPosts.requisition_status=="open",
JobPosts.is_deleted==False, # noqa: E712
)
if department:
open_posts_q=open_posts_q.where(JobPosts.department==department)
open_posts=int((await self.session.execute(open_posts_q)).scalar_one() or 0)
open_reqs=max(open_assign,open_posts)
avg_tth=await self._avg_time_to_hire(
from_date,to_date,department,recruiter_id=str(user.id)
)
rows.append(serialize_recruiter_row(user.id,user.name,hires,open_reqs,avg_tth))
rows.sort(key=lambda r: r["hires"],reverse=True)
return rows[:top]

View File

@ -30,6 +30,10 @@ SKIP_SENDER_PREFIXES = ("noreply", "no-reply", "donotreply", "do-not-reply",
"mailer-daemon", "postmaster", "bounce")
def _now() -> datetime:
return datetime.now(timezone.utc)
class Inbox(SQLModel, table=True):
__tablename__ = "inbox"
@ -42,8 +46,12 @@ class Inbox(SQLModel, table=True):
message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id")
messages: Optional["Inbox_Messages"] = Relationship(back_populates="inbox")
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
# tz-AWARE, matching every other timestamp the analytics layer filters on.
# A naive column here made asyncpg reject the aware UTC bounds that
# analytics/views.py builds, so /analytics/hiring-trend and /analytics/kpis
# both 500'd before the query ever reached Postgres.
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
favorite: Optional[bool] = Field(default=False)
rating: Optional[float] = Field(default=0.0)
@ -132,6 +140,20 @@ class Inbox(SQLModel, table=True):
result=await session.execute(select(cls).where(cls.id==iid))
return result.scalars().first()
@classmethod
async def get_inbox_with_message(cls,session:AsyncSession,record_id:int|str|None):
"""Inbox row with `messages` selectin-loaded for stage / application writers."""
if record_id is None:
return None
try:
iid=int(record_id)
except (TypeError,ValueError):
return None
result=await session.execute(
select(cls).options(selectinload(cls.messages)).where(cls.id==iid)
)
return result.scalars().first()
@classmethod
async def get_inbox_by_message_id(cls,session:AsyncSession,message_id):
try:
@ -161,7 +183,7 @@ class Inbox(SQLModel, table=True):
return None
for key,value in fields.items():
setattr(row,key,value)
row.updated_at=datetime.now()
row.updated_at=_now()
session.add(row)
await session.commit()
await session.refresh(row)
@ -175,7 +197,7 @@ class Inbox_Alerts(SQLModel, table=True):
alert_sender_name: str
alert_sender_email: str
is_read: bool = Field(default=False)
recieve_time: datetime = Field(default_factory=datetime.now)
recieve_time: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
inbox: list[Inbox] = Relationship(back_populates="alerts")
@ -214,7 +236,15 @@ class Inbox_Messages(SQLModel, table=True):
matched_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
candidate_phone_number: str | None = Field(default="xxx-xxx-xxxx")
candidate_education: str | None = Field(default=None)
current_employment: str | None = Field(default=None)
current_employment: str | None = Field(default=None)
# Denormalised dashboard / list-screen fields. server_default is load-bearing
# for every NOT NULL column — these arrive as ALTERs on a populated table.
ats_score: float | None = Field(default=None)
ats_band: str = Field(default="", sa_column_kwargs={"server_default": ""})
recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
is_duplicate: bool = Field(default=False, sa_column_kwargs={"server_default": "false"})
source_channel_id: int | None = Field(default=None, foreign_key="source_channels.id")
processing_state: str = Field(default="unread", sa_column_kwargs={"server_default": "unread"})
inbox: list[Inbox] = Relationship(back_populates="messages")
@staticmethod
@ -533,3 +563,74 @@ class Inbox_Messages(SQLModel, table=True):
await session.commit()
await session.refresh(row)
return row
class SourceChannels(SQLModel, table=True):
__tablename__ = "source_channels"
id: int | None = Field(default=None, primary_key=True)
key: str = Field(max_length=40, unique=True, index=True)
label: str
is_active: bool = Field(default=True)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id: int):
result = await session.execute(select(cls).where(cls.id == record_id))
return result.scalars().first()
@classmethod
async def get_by_key(cls, session: AsyncSession, key: str):
result = await session.execute(select(cls).where(cls.key == key))
return result.scalars().first()
@classmethod
async def list_active(cls, session: AsyncSession):
result = await session.execute(
select(cls).where(cls.is_active == True).order_by(cls.id.asc()) # noqa: E712
)
return list(result.scalars().all())
class AtsResults(SQLModel, table=True):
__tablename__ = "ats_results"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
inbox_id: int = Field(index=True, foreign_key="inbox.id")
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
overall_score: float = Field(default=0.0)
band: str = Field(default="")
is_current: bool = Field(default=True)
superseded_by_id: uuid.UUID | None = Field(default=None, foreign_key="ats_results.id")
model_name: str | None = Field(default=None)
# default_factory was datetime.now: a naive LOCAL value bound to a timestamptz
# column, which asyncpg reads as UTC. That silently backdated every row by the
# host's offset (+5 h here) instead of raising, unlike the naive-column case.
computed_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_current_for_inbox(cls, session: AsyncSession, inbox_id: int):
result = await session.execute(
select(cls)
.where(cls.inbox_id == int(inbox_id), cls.is_current == True) # noqa: E712
.order_by(cls.computed_at.desc())
)
return result.scalars().first()
@classmethod
async def insert_result(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return row

View File

@ -104,10 +104,13 @@ def serialize_application(message: Inbox_Messages) -> dict:
"match_status": message.match_status,
"match_error": message.match_error,
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
"ats_score": None,
"ats_score": message.ats_score,
"ats_band": message.ats_band or None,
"phone": message.candidate_phone_number,
"experience": message.experience or "",
"current_employment": message.current_employment or "",
"recruiter": None,
"duplicate": None,
"recruiter": str(message.recruiter_id) if message.recruiter_id else None,
"duplicate": message.is_duplicate,
"processing_state": message.processing_state,
"source_channel_id": message.source_channel_id,
}

View File

@ -1,10 +1,13 @@
"""Inbox Taskiq tasks — CV → job-post matching."""
"""Inbox Taskiq tasks — CV → job-post matching and ATS scoring."""
from __future__ import annotations
import logging
from datetime import datetime,timezone
from fastapi import HTTPException
from sqlalchemy import select
from agent.execute_agent import run_agent
from db_setup import session_scope
from employment_agent.execute_agent import run_employment_agent
@ -19,6 +22,58 @@ logger=logging.getLogger("inbox.tasks")
_DONE=frozenset({"matched","skipped","no_text","failed","dlq"})
async def score_message_against_job(record_id:str,job_id:str) -> dict:
"""ATS-score one inbox CV against one job post — the no-upload path.
The decoded attachment already on disk is the CV; the job post in the
database is the JD. Idempotent: a (message, job) pair with a completed
score is never paid for twice; re-runs are a no-op.
"""
# Lazy imports: inbox.plugins imports job.candidate.views, so a top-level
# import here would be circular.
from job.candidate.models import Candidates
from job.candidate.views import CandidateScoring
mid=Candidates._as_uuid(record_id)
jid=Candidates._as_uuid(job_id)
if mid is None or jid is None:
raise PermanentTaskError("record_id and job_id must be uuids")
async with session_scope() as session:
existing=await session.execute(
select(Candidates).where(
Candidates.inbox_message_id==mid,
Candidates.job_id==jid,
Candidates.status=="completed",
)
)
if existing.scalars().first() is not None:
return {"status":"already_scored"}
job=await JobPosts.get_job_post_by_id(session,job_id)
if job is None or job.is_deleted:
raise PermanentTaskError("job post missing or deleted")
service=CandidateScoring(session=session)
try:
# Attribute the rows to the job's owner — there is no request user
# in a background task.
results=await service.score_inbox(job_id,[record_id],{"id":str(job.created_by)})
except HTTPException as exc:
# 400/404 from score_inbox are permanent (no attachment, bad ids);
# retrying cannot fix them.
raise PermanentTaskError(str(exc.detail)) from exc
return {"status":"scored","results":len(results)}
@broker.task(
task_name="inbox.score_message",
retry_on_error=True,
max_retries=MAX_RETRIES,
delay=RETRY_DELAY,
)
async def score_inbox_message(record_id:str,job_id:str) -> dict:
return await score_message_against_job(record_id,job_id)
@broker.task(
task_name="inbox.match_message",
retry_on_error=True,
@ -81,6 +136,24 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
status=status,
error=result.get("error") or "",
)
# Auto-score: the match just paired this CV with jobs, so run the ATS on the
# spot — assigned job first, else the agent's top suggestion. Scoring failures
# must not fail the match; the match result is already committed above.
suggested=[str(j) for j in (result.get("suggested_job_post_ids") or []) if j]
score_job_id=None
async with session_scope() as session:
fresh=await Inbox_Messages.get_inbox_message_by_id(session,record_id)
if fresh is not None and fresh.assigned_job_post_id:
score_job_id=str(fresh.assigned_job_post_id)
if score_job_id is None and suggested:
score_job_id=suggested[0]
if score_job_id:
try:
outcome=await score_message_against_job(record_id,score_job_id)
logger.info("ats auto-score %s vs %s: %s",record_id,score_job_id,outcome.get("status"))
except Exception as exc:
logger.warning("ats auto-score failed for %s vs %s: %s",record_id,score_job_id,exc)
return {
"status":status,
"suggested_job_post_ids":result.get("suggested_job_post_ids") or [],

View File

@ -179,6 +179,20 @@ class Email:
updated=await Inbox_Messages.set_assigned_job_post(self.session,record_id,job_post_id)
if not updated:
raise HTTPException(status_code=404,detail="Message not found")
if job_post_id is not None:
# Assignment pairs this CV with a JD we already have — queue the ATS
# score in the background so the recruiter is not held on an OpenAI
# call. Idempotent server-side; broker-down just logs (the profile's
# Score-with-ATS button remains the manual fallback).
from inbox.tasks import score_inbox_message
try:
await score_inbox_message.kicker().with_labels(
created_at=datetime.now(timezone.utc).isoformat(),
correlation_id=str(record_id),
queue="inbox",
).kiq(str(record_id),str(job_post_id))
except Exception as exc:
logger.warning("could not queue ats score for %s: %s",record_id,exc)
return await self.get_inbox_message_by_id(record_id)
async def mark_read(self,record_id):

View File

@ -28,16 +28,32 @@ class ActivityLog:
return row
return None
async def get_activity(self,activity_id=None,inbox_id=None):
async def get_activity(self,activity_id=None,inbox_id=None,top=None,skip=0):
if activity_id:
row=await Activity.get_activity_by_id(self.session,activity_id)
if not row:
raise HTTPException(status_code=404,detail="Activity not found")
return serialize_activity(row)
if inbox_id is None:
raise HTTPException(status_code=400,detail="activity_id or inbox_id is required")
rows=await Activity.get_activity_by_inbox(self.session,int(inbox_id))
return [serialize_activity(r) for r in rows]
if inbox_id is not None:
rows=await Activity.get_activity_by_inbox(self.session,int(inbox_id))
return [serialize_activity(r) for r in rows]
if top is not None:
return await self.get_activity_feed(top=top,skip=skip)
raise HTTPException(status_code=400,detail="activity_id or inbox_id is required")
async def get_activity_feed(self,top,skip=0):
rows,total=await Activity.get_activity_feed(self.session,top=top,skip=skip)
items=[]
for r in rows:
data=serialize_activity(r)
actor_name=None
if r.inbox_id is not None:
inbox=await Inbox.get_inbox_by_id(self.session,r.inbox_id)
if inbox and getattr(inbox,"user",None):
actor_name=inbox.user.name
data["actor_name"]=actor_name
items.append(data)
return items,total
async def create_activity(self,payload):
link=await self._resolve_inbox(payload)

View File

@ -2,14 +2,19 @@ from fastapi import APIRouter,Depends,Query
from fastapi.responses import JSONResponse
from fastapi import HTTPException
from db_setup import get_session
from job.candidate.views import FileRead,CandidateView
from job.candidate.views import CandidateScoring,FileRead,CandidateView
from job.interviews.views import Interview
from job.notes.views import Note
from job.activity.views import ActivityLog
from job.feedback.views import FeedbackView
from job.pipeline.views import Pipeline
from job.assignment.views import Assignment
from job.cost.views import HiringCost
from sqlalchemy.ext.asyncio import AsyncSession
from users.permissions import PermissionTag, require_permission
from job.job_post.views import JobPost,JobPostCreate
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
import logging
from job.job_post.plugins import PlatformAlias
from fastapi import UploadFile, File, Form
@ -84,6 +89,32 @@ class FeedbackUpdate(BaseModel):
reviewed_by: UUID | None = None
class StageChange(BaseModel):
inbox_id: int
to_stage: str
change_reason: str | None = None
class JobAssignmentCreate(BaseModel):
job_post_id: UUID
user_id: UUID
assignment_role: str | None = None
class ApplicationAssignmentCreate(BaseModel):
inbox_id: int
user_id: UUID
assignment_role: str | None = None
class HiringCostCreate(BaseModel):
cost_type: str
amount: float
job_post_id: UUID | None = None
currency: str | None = None
description: str | None = None
incurred_at: datetime | None = None
@router.get("/jobs/alias")
async def get_job_alias():
@ -224,6 +255,64 @@ async def buffer_channels(
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
class InboxScoreRequest(BaseModel):
job_id: str
message_ids: list[str] # inbox_messages PK uuids, not Graph message ids
@router.post("/candidate/score")
async def score_candidates(
job_id: str = Form(...),
files: list[UploadFile] = File(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
"""Score uploaded CV PDFs against a job post; persists and returns the leaderboard."""
try:
pairs=[(f.filename,await f.read()) for f in files]
service=CandidateScoring(session=session)
data=await service.score_uploads(job_id,pairs,current_user)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/score_inbox")
async def score_inbox_candidates(
payload: InboxScoreRequest,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
"""Score the decoded attachments of inbox messages against a job post."""
try:
service=CandidateScoring(session=session)
data=await service.score_inbox(payload.job_id,payload.message_ids,current_user)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/scored/fetch")
async def fetch_scored_candidates(
job_id: str = Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Persisted leaderboard: completed by score desc, failures last. Without job_id
returns the whole pool across jobs."""
try:
service=CandidateScoring(session=session)
data=await service.fetch_candidates(job_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/fetch")
async def fetch_job_posts(
@ -232,7 +321,13 @@ async def fetch_job_posts(
skip: int = Query(0, ge=0),
ids: str | None = Query(None),
active_only: bool = Query(True),
current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_VIEW)),
# Either job-board or candidate viewers may list jobs — recruiters scoring
# CVs need a job to score against (CV Import picker).
current_user: dict = Depends(
require_permission(
PermissionTag.JOB_BOARD_VIEW, PermissionTag.CANDIDATES_VIEW, require_all=False
)
),
session: AsyncSession = Depends(get_session),
):
try:
@ -251,6 +346,23 @@ async def fetch_job_posts(
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch_by_id")
async def fetch_candidate_by_id(
candidate_id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateScoring(session=session)
data=await service.fetch_candidate_by_id(candidate_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch")
async def fetch_candidate(
user_id:str=Query(None),
@ -294,13 +406,29 @@ async def update_candidate(
async def fetch_interview(
interview_id:str=Query(None),
inbox_id:int=Query(None),
from_date:datetime=Query(None),
to_date:datetime=Query(None),
status:str=Query(None),
top:int=Query(None),
skip:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Interview(session=session)
data=await service.get_interview(interview_id=interview_id,inbox_id=inbox_id)
total=1 if isinstance(data,dict) else len(data)
if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or top is not None):
data,total=await service.get_interviews_range(
from_date=from_date,to_date=to_date,status=status,top=top,skip=skip,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
data=await service.get_interview(
interview_id=interview_id,inbox_id=inbox_id,
from_date=from_date,to_date=to_date,status=status,top=top,skip=skip,
)
if isinstance(data,tuple):
data,total=data
else:
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
@ -396,13 +524,21 @@ async def update_note(
async def fetch_activity(
activity_id:str=Query(None),
inbox_id:int=Query(None),
top:int=Query(None),
skip:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=ActivityLog(session=session)
data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id)
total=1 if isinstance(data,dict) else len(data)
if not activity_id and inbox_id is None and top is not None:
data,total=await service.get_activity_feed(top=top,skip=skip)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id,top=top,skip=skip)
if isinstance(data,tuple):
data,total=data
else:
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
@ -475,3 +611,141 @@ async def update_feedback(
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/candidate/stage")
async def change_candidate_stage(
payload:StageChange,
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Pipeline(session=session)
data=await service.change_stage(
payload.inbox_id,payload.to_stage,current_user,change_reason=payload.change_reason,
)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/pipeline/transitions/fetch")
async def fetch_pipeline_transitions(
transition_id:str=Query(None),
inbox_id:int=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Pipeline(session=session)
data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id)
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/assignments/fetch")
async def fetch_job_assignments(
job_post_id:str=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.list_job_assignments(job_post_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/job/assignments/create")
async def create_job_assignment(
payload:JobAssignmentCreate,
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.create_job_assignment(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/assignments/fetch")
async def fetch_application_assignments(
inbox_id:int=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.list_application_assignments(inbox_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/assignments/create")
async def create_application_assignment(
payload:ApplicationAssignmentCreate,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.create_application_assignment(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/costs/fetch")
async def fetch_hiring_costs(
job_post_id:str=Query(None),
from_date:datetime=Query(None),
to_date:datetime=Query(None),
top:int=Query(None),
skip:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=HiringCost(session=session)
data,total=await service.list_costs(
job_post_id=job_post_id,from_date=from_date,to_date=to_date,top=top,skip=skip,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/job/costs/create")
async def create_hiring_cost(
payload:HiringCostCreate,
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=HiringCost(session=session)
data=await service.create_cost(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))

View File

@ -0,0 +1,120 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
def _now() -> datetime:
return datetime.now(timezone.utc)
class JobAssignments(SQLModel, table=True):
__tablename__ = "job_assignments"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
job_post_id: uuid.UUID = Field(index=True, foreign_key="job_posts.id")
user_id: uuid.UUID = Field(foreign_key="users.id")
assignment_role: str = Field(default="primary_recruiter")
valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
assigned_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def fetch_by_job(cls, session: AsyncSession, job_post_id, *, current_only: bool = True):
uid = cls._as_uuid(job_post_id)
if uid is None:
return []
statement = select(cls).where(cls.job_post_id == uid)
if current_only:
statement = statement.where(cls.valid_to.is_(None))
statement = statement.order_by(cls.valid_from.desc())
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def insert_assignment(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_by_id(session, row.id)
@classmethod
async def count_open_reqs_by_user(cls, session: AsyncSession, user_id):
uid = cls._as_uuid(user_id)
if uid is None:
return 0
statement = (
select(func.count())
.select_from(cls)
.where(cls.user_id == uid, cls.valid_to.is_(None))
)
result = await session.execute(statement)
return result.scalar_one()
class ApplicationAssignments(SQLModel, table=True):
__tablename__ = "application_assignments"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
inbox_id: int = Field(index=True, foreign_key="inbox.id")
user_id: uuid.UUID = Field(foreign_key="users.id")
assignment_role: str = Field(default="primary_recruiter")
valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
assigned_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def fetch_by_inbox(cls, session: AsyncSession, inbox_id: int, *, current_only: bool = True):
statement = select(cls).where(cls.inbox_id == int(inbox_id))
if current_only:
statement = statement.where(cls.valid_to.is_(None))
statement = statement.order_by(cls.valid_from.desc())
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def insert_assignment(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_by_id(session, row.id)
import users.models as _users_models # noqa: E402, F401

View File

@ -0,0 +1,24 @@
def serialize_job_assignment(row) -> dict:
return {
"id": str(row.id),
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"user_id": str(row.user_id) if row.user_id else None,
"assignment_role": row.assignment_role,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
"assigned_by": str(row.assigned_by) if row.assigned_by else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
def serialize_application_assignment(row) -> dict:
return {
"id": str(row.id),
"inbox_id": row.inbox_id,
"user_id": str(row.user_id) if row.user_id else None,
"assignment_role": row.assignment_role,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
"assigned_by": str(row.assigned_by) if row.assigned_by else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}

View File

@ -0,0 +1,69 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from job.assignment.models import ApplicationAssignments, JobAssignments
from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment
from role.models import EnumRoles, Roles
from users.models import Users
class Assignment:
def __init__(self,session:AsyncSession):
self.session=session
async def _require_recruiter(self,user_id):
role=await Roles.get_role_by_name(self.session,EnumRoles.RECRUITER.value)
user=await Users.get_user_by_id(self.session,user_id)
if not role or not user or user.role_id!=role.id:
raise HTTPException(status_code=422,detail="user_id must be a recruiter")
return user
async def list_job_assignments(self,job_post_id):
if not job_post_id:
raise HTTPException(status_code=400,detail="job_post_id is required")
rows=await JobAssignments.fetch_by_job(self.session,job_post_id)
return [serialize_job_assignment(r) for r in rows]
async def create_job_assignment(self,payload,current_user):
user_id=payload.get("user_id")
job_post_id=payload.get("job_post_id")
if not user_id or not job_post_id:
raise HTTPException(status_code=422,detail="user_id and job_post_id are required")
await self._require_recruiter(user_id)
fields={
"job_post_id":JobAssignments._as_uuid(job_post_id),
"user_id":JobAssignments._as_uuid(user_id),
"assignment_role":payload.get("assignment_role") or "primary_recruiter",
"assigned_by":JobAssignments._as_uuid(
current_user.get("id") if isinstance(current_user,dict) else None
),
}
if not fields["job_post_id"] or not fields["user_id"] or not fields["assigned_by"]:
raise HTTPException(status_code=422,detail="Invalid job_post_id, user_id, or assigned_by")
row=await JobAssignments.insert_assignment(self.session,fields)
return serialize_job_assignment(row)
async def list_application_assignments(self,inbox_id):
if inbox_id is None:
raise HTTPException(status_code=400,detail="inbox_id is required")
rows=await ApplicationAssignments.fetch_by_inbox(self.session,int(inbox_id))
return [serialize_application_assignment(r) for r in rows]
async def create_application_assignment(self,payload,current_user):
user_id=payload.get("user_id")
inbox_id=payload.get("inbox_id")
if not user_id or inbox_id is None:
raise HTTPException(status_code=422,detail="user_id and inbox_id are required")
await self._require_recruiter(user_id)
fields={
"inbox_id":int(inbox_id),
"user_id":ApplicationAssignments._as_uuid(user_id),
"assignment_role":payload.get("assignment_role") or "primary_recruiter",
"assigned_by":ApplicationAssignments._as_uuid(
current_user.get("id") if isinstance(current_user,dict) else None
),
}
if not fields["user_id"] or not fields["assigned_by"]:
raise HTTPException(status_code=422,detail="Invalid user_id or assigned_by")
row=await ApplicationAssignments.insert_assignment(self.session,fields)
return serialize_application_assignment(row)

View File

@ -2,7 +2,8 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime
from sqlalchemy import JSON, DateTime, func, UniqueConstraint
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select
@ -112,6 +113,120 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
return row
class Candidates(SQLModel, table=True):
"""One scored (or failed-to-score) CV against one job post.
The dedupe key is (job_id, content_sha256), not the filename: inbox attachments
are stored by basename so different candidates can collide on "resume.pdf", while
identical bytes can arrive via both upload and email. Re-scoring the same bytes
against the same job updates the existing row (fresh model output, updated_at
bumped) instead of duplicating it. content_sha256 is NULL when the file bytes
were never readable (missing on disk); NULLs never conflict in the unique index.
"""
__tablename__ = "candidates"
__table_args__ = (UniqueConstraint("job_id", "content_sha256"),)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
job_id: uuid.UUID = Field(foreign_key="job_posts.id", index=True)
source: str = Field(default="upload") # "upload" | "inbox"
inbox_message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id")
filename: str
file_path: str | None = Field(default=None) # decoded-attachment path (inbox only)
content_sha256: str | None = Field(default=None, index=True)
candidate_name: str | None = Field(default=None)
job_title: str | None = Field(default=None)
current_company: str | None = Field(default=None)
years_experience: int | None = Field(default=None)
match_score: int | None = Field(default=None) # None on failed rows
matched_keywords: list[str] = Field(default_factory=list, sa_type=JSON)
missing_keywords: list[str] = Field(default_factory=list, sa_type=JSON)
summary_critique: str | None = Field(default=None)
status: str # "completed" | "failed"
error_code: str | None = Field(default=None)
error_message: str | None = Field(default=None)
model: str | None = Field(default=None) # which OPENAI_MODEL produced the score
created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_candidate_by_id(cls, session: AsyncSession, record_id: str):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def get_candidates_by_job(cls, session: AsyncSession, job_id: str | None = None):
"""Leaderboard order: completed by score desc, failures last, ties stable.
job_id=None returns the whole pool across jobs (same ordering) for the
frontend's unscoped Candidates/Talent Pool views.
"""
statement = select(cls)
if job_id is not None:
uid = cls._as_uuid(job_id)
if uid is None:
return []
statement = statement.where(cls.job_id == uid)
statement = statement.order_by(
cls.status.asc(), # "completed" < "failed"
cls.match_score.desc().nulls_last(),
cls.created_at.asc(),
)
result = await session.execute(statement)
return result.scalars().all()
@classmethod
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
existing = None
sha = fields.get("content_sha256")
if sha:
result = await session.execute(
select(cls).where(cls.job_id == fields["job_id"], cls.content_sha256 == sha)
)
existing = result.scalars().first()
if existing is None:
row = cls(**fields)
session.add(row)
try:
await session.commit()
except IntegrityError:
# A concurrent request inserted the same (job_id, sha) first; take over
# that row and update it instead.
await session.rollback()
result = await session.execute(
select(cls).where(cls.job_id == fields["job_id"], cls.content_sha256 == sha)
)
existing = result.scalars().first()
if existing is None:
raise
else:
await session.refresh(row)
return row
for key, value in fields.items():
setattr(existing, key, value)
existing.updated_at = _now()
session.add(existing)
await session.commit()
await session.refresh(existing)
return existing
class Interviews(SQLModel, table=True):
__tablename__ = "interviews"
@ -148,6 +263,34 @@ class Interviews(SQLModel, table=True):
)
return result.scalars().all()
@classmethod
async def get_interviews_in_range(
cls,
session: AsyncSession,
*,
from_date=None,
to_date=None,
status: str | None = None,
top: int | None = None,
skip: int = 0,
):
statement = select(cls)
if from_date is not None:
statement = statement.where(cls.interview_date >= from_date)
if to_date is not None:
statement = statement.where(cls.interview_date < to_date)
if status:
statement = statement.where(cls.interview_status == status)
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.interview_date.asc())
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.scalars().all()), total
@classmethod
async def insert_interview(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
@ -269,6 +412,19 @@ class Activity(SQLModel, table=True):
)
return result.scalars().all()
@classmethod
async def get_activity_feed(cls, session: AsyncSession, *, top: int | None = None, skip: int = 0):
statement = select(cls)
count_statement = select(func.count()).select_from(cls)
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.activity_date.desc(), cls.activity_time.desc())
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.scalars().all()), total
@classmethod
async def insert_activity(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
@ -353,4 +509,85 @@ class Feedback(SQLModel, table=True):
return row
class ApplicationStageTransitions(SQLModel, table=True):
"""Temporal history of inbox_messages.application_status changes.
valid_from / valid_to make time-in-stage a subtraction rather than a window
function. NULL valid_to means the stage is still current.
"""
__tablename__ = "application_stage_transitions"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
inbox_id: int = Field(index=True, foreign_key="inbox.id")
from_stage: str | None = Field(default=None)
to_stage: str
valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
changed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
actor_kind: str = Field(default="user")
change_reason: str | None = Field(default=None)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def fetch_by_inbox(cls, session: AsyncSession, inbox_id: int):
result = await session.execute(
select(cls).where(cls.inbox_id == int(inbox_id)).order_by(cls.valid_from.desc())
)
return list(result.scalars().all())
@classmethod
async def get_open_transition(cls, session: AsyncSession, inbox_id: int):
result = await session.execute(
select(cls)
.where(cls.inbox_id == int(inbox_id), cls.valid_to.is_(None))
.order_by(cls.valid_from.desc())
)
return result.scalars().first()
@classmethod
async def insert_transition(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
row = cls(**fields)
session.add(row)
if commit:
await session.commit()
await session.refresh(row)
return row
@classmethod
async def close_open(cls, session: AsyncSession, inbox_id: int, *, at: datetime | None = None, commit: bool = False):
row = await cls.get_open_transition(session, inbox_id)
if not row:
return None
row.valid_to = at or _now()
session.add(row)
if commit:
await session.commit()
await session.refresh(row)
return row
@classmethod
async def count_by_inbox(cls, session: AsyncSession, inbox_id: int):
statement = select(func.count()).select_from(cls).where(cls.inbox_id == int(inbox_id))
result = await session.execute(statement)
return result.scalar_one()
import users.models as _users_models # noqa: E402, F401

View File

@ -1,4 +1,4 @@
"""CV text cleanup helpers for the PDF extractor.
"""CV text cleanup, scoring-JD builder, and the ATS scorer singleton.
Pure module: no FastAPI imports and no HTTPException.
@ -6,14 +6,93 @@ Designer-made resumes position every glyph individually, so pypdf hands back
"S K I L L S" instead of "SKILLS". In that layout a single space is glyph
padding and a run of two or more spaces is the real word gap, which is what
the `despace_line` decorator keys off to rebuild readable lines.
The scoring pieces reuse the bulk-ats engine, installed editable from the repo
root (`pip install -e ..` -> `import app.*`), and share llm_setup's process-wide
AsyncOpenAI client rather than opening a second connection pool.
"""
from __future__ import annotations
import re
from app.core.config import Settings, get_settings
from app.services.llm import OpenAIScorer
from dotenv import load_dotenv
from job.candidate.decorators import despace_line, normalize_unicode
load_dotenv()
# Backend-local per-candidate error code for cases the bulk-ats engine never sees
# (its uploads always have bytes; inbox attachments can vanish from disk).
FILE_NOT_FOUND = "FILE_NOT_FOUND"
_scorer: OpenAIScorer | None = None
def get_scoring_settings() -> Settings:
"""Validated scoring knobs (model family, token floor, size limits).
Reads real env vars, which load_dotenv() above has populated from the nearest
.env (backend/.env, else the repo root). Shared names (OPENAI_MODEL,
OPENAI_MAX_OUTPUT_TOKENS) therefore match what llm_setup uses.
"""
return get_settings()
def get_scorer() -> OpenAIScorer:
"""Process-wide scorer over llm_setup's shared AsyncOpenAI client.
Lazy so that a missing/invalid OPENAI configuration surfaces on the first
scoring request, not at import; llm_setup.init_llm() in the app lifespan has
normally created and verified the client before this ever runs.
"""
global _scorer
if _scorer is None:
from llm_setup import get_client
settings = get_scoring_settings()
_scorer = OpenAIScorer(
get_client(),
model=settings.openai_model,
max_output_tokens=settings.openai_max_output_tokens,
effort=settings.openai_effort,
enable_cache=settings.openai_enable_prompt_cache,
)
return _scorer
def build_job_description(job) -> str:
"""Deterministic scoring JD from a JobPosts row.
Byte-stable per job: derived only from stored column values, in fixed order,
because OpenAI prompt caching works on exact prefix match one volatile byte
(an id, a timestamp) would stop the whole batch from reusing the cache.
Deliberately excludes post_text (hashtags, salary, LinkedIn formatting) and
salary; list order is taken as stored.
"""
lines = [f"Job Title: {job.title}"]
if job.employment_type:
lines.append(f"Employment Type: {job.employment_type}")
if job.location:
lines.append(f"Location: {job.location}")
if job.experience_min is not None and job.experience_max is not None:
lines.append(f"Experience Required: {job.experience_min}-{job.experience_max} years")
elif job.experience_min is not None:
lines.append(f"Experience Required: {job.experience_min}+ years")
elif job.experience_max is not None:
lines.append(f"Experience Required: up to {job.experience_max} years")
if job.description:
lines += ["", "Description:", job.description.strip()]
if job.requirements:
lines += ["", "Mandatory Requirements:"]
lines += [f"- {item}" for item in job.requirements]
if job.optional_skills:
lines += ["", "Preferred (nice to have):"]
lines += [f"- {item}" for item in job.optional_skills]
return "\n".join(lines)
@normalize_unicode
@despace_line

View File

@ -6,6 +6,32 @@ from job.interviews.serializers import serialize_interview
from job.activity.serializers import serialize_activity
from job.feedback.serializers import serialize_feedback
def serialize_candidate(row) -> dict:
return {
"id": str(row.id),
"job_id": str(row.job_id),
"inbox_message_id": str(row.inbox_message_id) if row.inbox_message_id else None,
"source": row.source,
"filename": row.filename,
"file_path": row.file_path,
"content_sha256": row.content_sha256,
"candidate_name": row.candidate_name,
"job_title": row.job_title,
"current_company": row.current_company,
"years_experience": row.years_experience,
"match_score": row.match_score,
"matched_keywords": list(row.matched_keywords or []),
"missing_keywords": list(row.missing_keywords or []),
"summary_critique": row.summary_critique,
"status": row.status,
"error_code": row.error_code,
"error_message": row.error_message,
"model": row.model,
"created_by": str(row.created_by),
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
return {

View File

@ -1,5 +1,5 @@
from sqlalchemy.ext.asyncio import AsyncSession
import base64,io,logging,os,uuid
import asyncio,base64,dataclasses,hashlib,io,logging,os,uuid
from datetime import datetime,timezone
from pathlib import Path
from dotenv import load_dotenv
@ -8,13 +8,26 @@ from pypdf import PdfReader
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlmodel import true
from app.core.errors import ATSError,ErrorCode
from app.models.scoring import CompletedCandidate
from app.services.pdf import extract_resume,sanitize_filename
from app.services.scoring import score_batch
from inbox.models import Inbox_Messages,Inbox
from job.candidate.models import Candidates
from job.candidate.plugins import (
FILE_NOT_FOUND,
build_job_description,
get_scorer,
get_scoring_settings,
normalize_spaced_text,
)
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
from job.candidate.serializers import serialize_candidate_profile,serialize_manual_upload_candidate
from job.candidate.serializers import serialize_manual_upload_candidate
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
from job.notes.serializers import serialize_note
from inbox.models import Inbox_Messages,Inbox
from job.candidate.plugins import extract_candidate_email,normalize_spaced_text
from job.candidate.plugins import extract_candidate_email
load_dotenv()
logger=logging.getLogger("job.candidate.views")
@ -229,10 +242,231 @@ class FileRead:
# get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id)
# get_file=
class CandidateScoring:
"""ATS scoring of CVs against one job post, persisted to the candidates table.
Complements the agent's inbox-match flow: the agent suggests WHICH job a CV is
for; this service scores HOW WELL a CV fits a chosen job (0-100 leaderboard).
Deviation from the bulk-ats HTTP API (which rejects a whole batch with 415/413
on a bad file): here per-file problems become persisted rows with
status="failed" so one broken attachment never sinks the rest of the batch.
Request-level errors (unknown job, too many files) still raise.
"""
def __init__(self,session:AsyncSession):
self.session=session
async def score_uploads(self,job_id,files,current_user):
"""files: list of (filename, bytes) pairs from the route handler."""
settings=get_scoring_settings()
if len(files)>settings.max_resumes_per_request:
raise HTTPException(
status_code=413,
detail=f"At most {settings.max_resumes_per_request} resumes per request",
)
sources=[]
for filename,data in files:
source={
"filename":filename or "resume.pdf",
"data":data,
"file_path":None,
"inbox_message_id":None,
"precheck":None,
}
if not (filename or "").lower().endswith(".pdf"):
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"Only PDF resumes are supported.")
elif len(data)>settings.max_pdf_size_bytes:
source["precheck"]=(ErrorCode.PAYLOAD_TOO_LARGE,"The file exceeds the size limit.")
sources.append(source)
return await self._score_and_persist(job_id,sources,"upload",current_user)
async def score_inbox(self,job_id,message_ids,current_user):
"""Score the decoded attachments of inbox messages (PK uuids, not Graph ids)."""
# Local import: inbox.plugins imports this module (FileRead), so a top-level
# import would be circular — same pattern as match_inbox_cv above.
from inbox.plugins import resolve_attachment_path
sources=[]
for mid in message_ids:
row=await Inbox_Messages.get_inbox_message_by_id(self.session,mid)
if row is None:
raise HTTPException(status_code=404,detail=f"Inbox message {mid} not found")
if not row.file_path:
continue
for path_str in (p.strip() for p in row.file_path.split(",") if p.strip()):
path=resolve_attachment_path(path_str)
source={
"filename":path.name,
"data":None,
"file_path":str(path),
"inbox_message_id":row.id,
"precheck":None,
}
suffix=path.suffix.lower()
if suffix in (".doc",".docx"):
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"DOC/DOCX extraction is not supported yet.")
elif suffix!=".pdf":
source["precheck"]=(ErrorCode.UNSUPPORTED_FILE_TYPE,"Only PDF resumes are supported.")
elif not path.is_file():
source["precheck"]=(FILE_NOT_FOUND,"The decoded attachment is missing on disk.")
else:
try:
source["data"]=await asyncio.to_thread(path.read_bytes)
except OSError:
source["precheck"]=(FILE_NOT_FOUND,"The decoded attachment could not be read.")
sources.append(source)
if not sources:
raise HTTPException(status_code=400,detail="No attachments found for the given message(s)")
return await self._score_and_persist(job_id,sources,"inbox",current_user)
async def fetch_candidates(self,job_id=None):
# job_id omitted -> the whole pool across jobs (frontend Candidates/TalentPool).
if job_id is not None:
job=await JobPosts.get_job_post_by_id(self.session,job_id)
if job is None or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
rows=await Candidates.get_candidates_by_job(self.session,job_id)
return [serialize_candidate(row) for row in rows]
async def fetch_candidate_by_id(self,candidate_id):
row=await Candidates.get_candidate_by_id(self.session,candidate_id)
if row is None:
raise HTTPException(status_code=404,detail="Candidate not found")
return serialize_candidate(row)
async def _score_and_persist(self,job_id,sources,source_kind,current_user):
job=await JobPosts.get_job_post_by_id(self.session,job_id)
if job is None or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
settings=get_scoring_settings()
jd=build_job_description(job)
if len(jd)>settings.max_jd_chars:
raise HTTPException(status_code=422,detail="The job post is too large to score against")
# Slot-indexed like app/api/routes.py: results merge back by position, never
# by filename — inbox attachments can share a basename.
results_by_slot={}
extracted=[]
for slot,source in enumerate(sources):
source["safe_name"]=sanitize_filename(source["filename"])
data=source["data"]
source["sha256"]=hashlib.sha256(data).hexdigest() if data is not None else None
if source["precheck"] is not None:
code,message=source["precheck"]
results_by_slot[slot]=self._failed_fields(source,code,message)
continue
try:
# pypdf is CPU-bound: keep it off the event loop. Despace BEFORE
# scoring so keyword verification sees the exact text the model saw;
# ExtractedResume is frozen, hence dataclasses.replace.
resume=await asyncio.to_thread(
extract_resume,data,source["safe_name"],settings.max_resume_chars
)
resume=dataclasses.replace(resume,text=normalize_spaced_text(resume.text))
except ATSError as exc:
results_by_slot[slot]=self._failed_fields(source,exc.error_code,exc.public_message)
continue
extracted.append((slot,resume))
scored=await score_batch(
[resume for _,resume in extracted],
job_description=jd,
scorer=get_scorer(),
concurrency=settings.scoring_concurrency,
)
for (slot,_),result in zip(extracted,scored,strict=True):
source=sources[slot]
if isinstance(result,CompletedCandidate):
results_by_slot[slot]={
**self._base_fields(source),
"status":"completed",
"candidate_name":result.candidate_name,
"job_title":result.job_title,
"current_company":result.current_company,
"years_experience":result.years_experience,
"match_score":result.match_score,
"matched_keywords":result.matched_keywords,
"missing_keywords":result.missing_keywords,
"summary_critique":result.summary_critique,
"error_code":None,
"error_message":None,
}
else:
results_by_slot[slot]=self._failed_fields(source,result.error_code,result.error_message)
common={
"job_id":job.id,
"source":source_kind,
"created_by":uuid.UUID(str(current_user["id"])),
"model":settings.openai_model,
}
rows=[]
for slot in range(len(sources)):
fields={**results_by_slot[slot],**common}
rows.append(await Candidates.upsert_candidate(self.session,fields))
# Leaderboard order: completed by score desc, failures last, stable.
rows.sort(key=lambda r:(0,-(r.match_score or 0)) if r.status=="completed" else (1,0))
return [serialize_candidate(row) for row in rows]
@staticmethod
def _base_fields(source):
return {
"inbox_message_id":source["inbox_message_id"],
"filename":source["safe_name"],
"file_path":source["file_path"],
"content_sha256":source["sha256"],
}
@classmethod
def _failed_fields(cls,source,code,message):
return {
**cls._base_fields(source),
"status":"failed",
"error_code":str(code),
"error_message":message,
"candidate_name":None,
"job_title":None,
"current_company":None,
"years_experience":None,
"match_score":None,
"matched_keywords":[],
"missing_keywords":[],
"summary_critique":None,
}
class CandidateView:
def __init__(self,session:AsyncSession):
self.session=session
@staticmethod
def _recommendation(score):
if score is None:
return None
# Same bands the frontend uses (Candidates.jsx / seed.js).
return "Strong Match" if score>=82 else "Potential Match" if score>=65 else "Weak Match"
async def _scores_by_message(self,message_ids):
"""Completed ATS scores (candidates table) per inbox message id, newest first.
One batched query the profile list would otherwise pay a query per row.
"""
mids=[m for m in message_ids if m]
if not mids:
return {}
result=await self.session.execute(
select(Candidates)
.where(Candidates.inbox_message_id.in_(mids),Candidates.status=="completed")
.order_by(Candidates.updated_at.desc())
)
scores={}
for row in result.scalars().all():
scores.setdefault(row.inbox_message_id,[]).append(row)
return scores
async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None):
try:
email=(candidate_email or "").strip().lower()
@ -335,11 +569,16 @@ class CandidateView:
"""Normalize list/single, serialize each record, attach full job_posts rows."""
single=not isinstance(data,list)
records=[data] if single else list(data or [])
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
enriched=[]
for record in records:
payload=serialize_candidate_profile(record)
payload["job_posts"]=[]
payload["assigned_job_post"]=None
scored=scores.get(getattr(record,"message_id",None)) or []
if scored:
payload["ai_score"]=scored[0].match_score
payload["recommendation"]=self._recommendation(scored[0].match_score)
assigned_id=payload.get("assigned_job_post_id")
if assigned_id:
await self.get_job_post_by_id(record_id=assigned_id,data=payload,as_assigned=True)
@ -410,6 +649,27 @@ class CandidateView:
)
notes=[serialize_note(r) for r in result.scalars().all()]
# ATS score: join the scoring engine's `candidates` rows onto the profile
# by inbox message. The serializer stubs ai_score/recommendation to None;
# this is where they get real values. Prefer the score against the
# assigned job post, else the most recent completed score.
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
scored_rows=[row for rows in scores.values() for row in rows]
if scored_rows:
assigned_uid=Candidates._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None
chosen=None
if assigned_uid is not None:
chosen=next((r for r in scored_rows if r.job_id==assigned_uid),None)
if chosen is None:
chosen=max(scored_rows,key=lambda r:r.updated_at)
base["ai_score"]=chosen.match_score
base["recommendation"]=self._recommendation(chosen.match_score)
base["matched_keywords"]=list(chosen.matched_keywords or [])
base["missing_keywords"]=list(chosen.missing_keywords or [])
base["summary_critique"]=chosen.summary_critique
base["scored_job_post_id"]=str(chosen.job_id)
base["scored_at"]=chosen.updated_at.isoformat() if chosen.updated_at else None
activity.sort(key=lambda r:(r.get("activity_date") or ""),reverse=True)
base["favorite"]=favorite
base["rating"]=rating

View File

@ -0,0 +1,91 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
def _now() -> datetime:
return datetime.now(timezone.utc)
class HiringCosts(SQLModel, table=True):
__tablename__ = "hiring_costs"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
cost_type: str = Field(default="other")
amount: float = Field(default=0.0)
currency: str = Field(default="USD")
incurred_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
description: str | None = Field(default=None)
created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def fetch_costs(
cls,
session: AsyncSession,
*,
job_post_id=None,
from_date=None,
to_date=None,
top: int | None = None,
skip: int = 0,
):
statement = select(cls)
if job_post_id is not None:
uid = cls._as_uuid(job_post_id)
if uid is not None:
statement = statement.where(cls.job_post_id == uid)
if from_date is not None:
statement = statement.where(cls.incurred_at >= from_date)
if to_date is not None:
statement = statement.where(cls.incurred_at < to_date)
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.incurred_at.desc())
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.scalars().all()), total
@classmethod
async def insert_cost(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_by_id(session, row.id)
@classmethod
async def sum_amount(cls, session: AsyncSession, *, from_date=None, to_date=None):
statement = select(func.coalesce(func.sum(cls.amount), 0.0))
if from_date is not None:
statement = statement.where(cls.incurred_at >= from_date)
if to_date is not None:
statement = statement.where(cls.incurred_at < to_date)
result = await session.execute(statement)
return float(result.scalar_one() or 0.0)
import users.models as _users_models # noqa: E402, F401

View File

@ -0,0 +1,13 @@
def serialize_hiring_cost(row) -> dict:
return {
"id": str(row.id),
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"cost_type": row.cost_type,
"amount": row.amount,
"currency": row.currency,
"incurred_at": row.incurred_at.isoformat() if row.incurred_at else None,
"description": row.description,
"created_by": str(row.created_by) if row.created_by else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}

44
backend/job/cost/views.py Normal file
View File

@ -0,0 +1,44 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from job.cost.models import HiringCosts
from job.cost.serializers import serialize_hiring_cost
class HiringCost:
def __init__(self,session:AsyncSession):
self.session=session
async def list_costs(self,job_post_id=None,from_date=None,to_date=None,top=None,skip=0):
rows,total=await HiringCosts.fetch_costs(
self.session,
job_post_id=job_post_id,
from_date=from_date,
to_date=to_date,
top=top,
skip=skip,
)
return [serialize_hiring_cost(r) for r in rows],total
async def create_cost(self,payload,current_user):
cost_type=payload.get("cost_type")
amount=payload.get("amount")
if not cost_type or amount is None:
raise HTTPException(status_code=422,detail="cost_type and amount are required")
created_by=HiringCosts._as_uuid(
current_user.get("id") if isinstance(current_user,dict) else None
)
if not created_by:
raise HTTPException(status_code=422,detail="created_by is required")
fields={
"job_post_id":HiringCosts._as_uuid(payload.get("job_post_id")),
"cost_type":cost_type,
"amount":float(amount),
"currency":payload.get("currency") or "USD",
"description":payload.get("description"),
"created_by":created_by,
}
if payload.get("incurred_at") is not None:
fields["incurred_at"]=payload["incurred_at"]
row=await HiringCosts.insert_cost(self.session,fields)
return serialize_hiring_cost(row)

View File

@ -1,4 +1,6 @@
def serialize_interview(row) -> dict:
inbox=getattr(row,"inbox",None)
user=getattr(inbox,"user",None) if inbox else None
return {
"id": str(row.id),
"inbox_id": row.inbox_id,
@ -6,4 +8,5 @@ def serialize_interview(row) -> dict:
"interview_time": row.interview_time.isoformat() if row.interview_time else None,
"interview_type": row.interview_type,
"interview_status": row.interview_status,
"candidate_name": user.name if user else None,
}

View File

@ -9,16 +9,31 @@ class Interview:
def __init__(self,session:AsyncSession):
self.session=session
async def get_interview(self,interview_id=None,inbox_id=None):
async def get_interview(self,interview_id=None,inbox_id=None,from_date=None,to_date=None,status=None,top=None,skip=0):
if interview_id:
row=await Interviews.get_interview_by_id(self.session,interview_id)
if not row:
raise HTTPException(status_code=404,detail="Interview not found")
return serialize_interview(row)
if inbox_id is None:
raise HTTPException(status_code=400,detail="interview_id or inbox_id is required")
rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id))
return [serialize_interview(r) for r in rows]
if inbox_id is not None:
rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id))
return [serialize_interview(r) for r in rows]
if from_date is not None or to_date is not None or status is not None or top is not None:
return await self.get_interviews_range(
from_date=from_date,to_date=to_date,status=status,top=top,skip=skip,
)
raise HTTPException(status_code=400,detail="interview_id or inbox_id is required")
async def get_interviews_range(self,from_date=None,to_date=None,status=None,top=None,skip=0):
rows,total=await Interviews.get_interviews_in_range(
self.session,
from_date=from_date,
to_date=to_date,
status=status,
top=top,
skip=skip,
)
return [serialize_interview(r) for r in rows],total
async def create_interview(self,payload):
fields={

View File

@ -20,9 +20,15 @@ class JobPosts(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
title: str = Field(index=True)
# foreign_keys is required, not decoration: current_recruiter_id below is a
# SECOND foreign key into users.id, so the join condition is ambiguous without
# it and every mapper fails to initialize. `user` is the AUTHOR of the post —
# current_recruiter_id is deliberately a bare column with no relationship of
# its own, because Users already carries five selectin relations that load on
# every authenticated request. Same pairing as Notes.user / Notes.author.
user: Optional["Users"] = Relationship(
back_populates="job_posts",
sa_relationship_kwargs={"lazy": "joined"},
sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"},
)
platform: str = Field(default="linkedin")
@ -43,6 +49,14 @@ class JobPosts(SQLModel, table=True):
buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
status: str = Field(default="draft")
buffer_error: str | None = Field(default=None)
# requisition_status is the hiring lifecycle (open/closed/on_hold). Distinct from
# `status`, which tracks Buffer publishing (draft/scheduled/published/failed).
# server_default is load-bearing: this column arrives as an ALTER on a populated table.
requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"})
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))

View File

@ -0,0 +1,13 @@
def serialize_stage_transition(row) -> dict:
return {
"id": str(row.id),
"inbox_id": row.inbox_id,
"from_stage": row.from_stage,
"to_stage": row.to_stage,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
"changed_by": str(row.changed_by) if row.changed_by else None,
"actor_kind": row.actor_kind,
"change_reason": row.change_reason,
"created_at": row.created_at.isoformat() if row.created_at else None,
}

View File

@ -0,0 +1,64 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from inbox.enums import Candidate_application_Status
from inbox.models import Inbox
from job.candidate.models import ApplicationStageTransitions
from job.pipeline.serializers import serialize_stage_transition
class Pipeline:
def __init__(self,session:AsyncSession):
self.session=session
async def get_transitions(self,inbox_id=None,transition_id=None):
if transition_id:
row=await ApplicationStageTransitions.get_by_id(self.session,transition_id)
if not row:
raise HTTPException(status_code=404,detail="Transition not found")
return serialize_stage_transition(row)
if inbox_id is None:
raise HTTPException(status_code=400,detail="transition_id or inbox_id is required")
rows=await ApplicationStageTransitions.fetch_by_inbox(self.session,int(inbox_id))
return [serialize_stage_transition(r) for r in rows]
async def change_stage(self,inbox_id,to_stage,current_user,change_reason=None):
inbox=await Inbox.get_inbox_with_message(self.session,inbox_id)
if not inbox:
raise HTTPException(status_code=404,detail="Inbox not found")
message=inbox.messages
if not message:
raise HTTPException(status_code=404,detail="Inbox message not found")
try:
stage=Candidate_application_Status(to_stage)
except ValueError:
raise HTTPException(status_code=422,detail="Invalid to_stage")
current=message.application_status
from_stage=current.value if isinstance(current,Candidate_application_Status) else str(current)
if from_stage==stage.value:
raise HTTPException(status_code=400,detail="already at stage")
changed_by=None
if isinstance(current_user,dict) and current_user.get("id"):
changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id"))
await ApplicationStageTransitions.close_open(self.session,inbox.id,commit=False)
transition_data={
"inbox_id":inbox.id,
"from_stage":from_stage,
"to_stage":stage.value,
"changed_by":changed_by,
"actor_kind":"user",
"change_reason":change_reason,
}
transition=await ApplicationStageTransitions.insert_transition(
self.session,
transition_data,
commit=False,
)
message.application_status=stage
self.session.add(message)
await self.session.commit()
return {
"inbox_id":inbox.id,
"application_status":stage.value,
"transition":serialize_stage_transition(transition),
}

View File

@ -10,6 +10,8 @@ from role.app import router as role_router
from forget_password.app import router as forget_password_router
from job.app import router as candidate_router
from notifications.app import router as confirmation_router
from analytics.app import router as analytics_router
from offer.app import router as offer_router
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
logger=logging.getLogger("main")
@ -79,3 +81,5 @@ app.include_router(role_router)
app.include_router(forget_password_router)
app.include_router(confirmation_router)
app.include_router(candidate_router)
app.include_router(analytics_router)
app.include_router(offer_router)

View File

@ -0,0 +1,242 @@
-- 001_dashboard_rbac_and_enum.sql
-- Manual one-shot: enum labels, permission tags, analytics_dashboard bundle,
-- source channels, and backfills. Run in psql against the app DB.
-- ADD VALUE cannot run inside a transaction that also uses the new labels —
-- run section 1a with autocommit (psql default outside BEGIN).
--
-- Order: (1) alembic upgrade for new tables/columns, (2) this file.
-- Section 1a (enum) can run before or after alembic.
-- =============================================================================
-- 1a. Extend candidate_application_status (unqualified — matches original migration)
-- =============================================================================
ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'SCREENING';
ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'ASSESSMENT';
ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'INTERVIEW';
ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'OFFER';
ALTER TYPE candidate_application_status ADD VALUE IF NOT EXISTS 'HIRED';
-- =============================================================================
-- 1b. Seed all 104 permission tags (module x action) idempotently
-- =============================================================================
INSERT INTO app.permission_tags
(tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
VALUES
('dashboard.view', 'dashboard', 'view', NULL, NOW(), NOW(), true, false),
('dashboard.create', 'dashboard', 'create', NULL, NOW(), NOW(), true, false),
('dashboard.edit', 'dashboard', 'edit', NULL, NOW(), NOW(), true, false),
('dashboard.delete', 'dashboard', 'delete', NULL, NOW(), NOW(), true, false),
('dashboard.approve', 'dashboard', 'approve', NULL, NOW(), NOW(), true, false),
('dashboard.export', 'dashboard', 'export', NULL, NOW(), NOW(), true, false),
('dashboard.manage', 'dashboard', 'manage', NULL, NOW(), NOW(), true, false),
('dashboard.configure', 'dashboard', 'configure', NULL, NOW(), NOW(), true, false),
('inbox.view', 'inbox', 'view', NULL, NOW(), NOW(), true, false),
('inbox.create', 'inbox', 'create', NULL, NOW(), NOW(), true, false),
('inbox.edit', 'inbox', 'edit', NULL, NOW(), NOW(), true, false),
('inbox.delete', 'inbox', 'delete', NULL, NOW(), NOW(), true, false),
('inbox.approve', 'inbox', 'approve', NULL, NOW(), NOW(), true, false),
('inbox.export', 'inbox', 'export', NULL, NOW(), NOW(), true, false),
('inbox.manage', 'inbox', 'manage', NULL, NOW(), NOW(), true, false),
('inbox.configure', 'inbox', 'configure', NULL, NOW(), NOW(), true, false),
('jobs.view', 'jobs', 'view', NULL, NOW(), NOW(), true, false),
('jobs.create', 'jobs', 'create', NULL, NOW(), NOW(), true, false),
('jobs.edit', 'jobs', 'edit', NULL, NOW(), NOW(), true, false),
('jobs.delete', 'jobs', 'delete', NULL, NOW(), NOW(), true, false),
('jobs.approve', 'jobs', 'approve', NULL, NOW(), NOW(), true, false),
('jobs.export', 'jobs', 'export', NULL, NOW(), NOW(), true, false),
('jobs.manage', 'jobs', 'manage', NULL, NOW(), NOW(), true, false),
('jobs.configure', 'jobs', 'configure', NULL, NOW(), NOW(), true, false),
('candidates.view', 'candidates', 'view', NULL, NOW(), NOW(), true, false),
('candidates.create', 'candidates', 'create', NULL, NOW(), NOW(), true, false),
('candidates.edit', 'candidates', 'edit', NULL, NOW(), NOW(), true, false),
('candidates.delete', 'candidates', 'delete', NULL, NOW(), NOW(), true, false),
('candidates.approve', 'candidates', 'approve', NULL, NOW(), NOW(), true, false),
('candidates.export', 'candidates', 'export', NULL, NOW(), NOW(), true, false),
('candidates.manage', 'candidates', 'manage', NULL, NOW(), NOW(), true, false),
('candidates.configure', 'candidates', 'configure', NULL, NOW(), NOW(), true, false),
('pipeline.view', 'pipeline', 'view', NULL, NOW(), NOW(), true, false),
('pipeline.create', 'pipeline', 'create', NULL, NOW(), NOW(), true, false),
('pipeline.edit', 'pipeline', 'edit', NULL, NOW(), NOW(), true, false),
('pipeline.delete', 'pipeline', 'delete', NULL, NOW(), NOW(), true, false),
('pipeline.approve', 'pipeline', 'approve', NULL, NOW(), NOW(), true, false),
('pipeline.export', 'pipeline', 'export', NULL, NOW(), NOW(), true, false),
('pipeline.manage', 'pipeline', 'manage', NULL, NOW(), NOW(), true, false),
('pipeline.configure', 'pipeline', 'configure', NULL, NOW(), NOW(), true, false),
('interviews.view', 'interviews', 'view', NULL, NOW(), NOW(), true, false),
('interviews.create', 'interviews', 'create', NULL, NOW(), NOW(), true, false),
('interviews.edit', 'interviews', 'edit', NULL, NOW(), NOW(), true, false),
('interviews.delete', 'interviews', 'delete', NULL, NOW(), NOW(), true, false),
('interviews.approve', 'interviews', 'approve', NULL, NOW(), NOW(), true, false),
('interviews.export', 'interviews', 'export', NULL, NOW(), NOW(), true, false),
('interviews.manage', 'interviews', 'manage', NULL, NOW(), NOW(), true, false),
('interviews.configure', 'interviews', 'configure', NULL, NOW(), NOW(), true, false),
('assessments.view', 'assessments', 'view', NULL, NOW(), NOW(), true, false),
('assessments.create', 'assessments', 'create', NULL, NOW(), NOW(), true, false),
('assessments.edit', 'assessments', 'edit', NULL, NOW(), NOW(), true, false),
('assessments.delete', 'assessments', 'delete', NULL, NOW(), NOW(), true, false),
('assessments.approve', 'assessments', 'approve', NULL, NOW(), NOW(), true, false),
('assessments.export', 'assessments', 'export', NULL, NOW(), NOW(), true, false),
('assessments.manage', 'assessments', 'manage', NULL, NOW(), NOW(), true, false),
('assessments.configure', 'assessments', 'configure', NULL, NOW(), NOW(), true, false),
('offers.view', 'offers', 'view', NULL, NOW(), NOW(), true, false),
('offers.create', 'offers', 'create', NULL, NOW(), NOW(), true, false),
('offers.edit', 'offers', 'edit', NULL, NOW(), NOW(), true, false),
('offers.delete', 'offers', 'delete', NULL, NOW(), NOW(), true, false),
('offers.approve', 'offers', 'approve', NULL, NOW(), NOW(), true, false),
('offers.export', 'offers', 'export', NULL, NOW(), NOW(), true, false),
('offers.manage', 'offers', 'manage', NULL, NOW(), NOW(), true, false),
('offers.configure', 'offers', 'configure', NULL, NOW(), NOW(), true, false),
('reports.view', 'reports', 'view', NULL, NOW(), NOW(), true, false),
('reports.create', 'reports', 'create', NULL, NOW(), NOW(), true, false),
('reports.edit', 'reports', 'edit', NULL, NOW(), NOW(), true, false),
('reports.delete', 'reports', 'delete', NULL, NOW(), NOW(), true, false),
('reports.approve', 'reports', 'approve', NULL, NOW(), NOW(), true, false),
('reports.export', 'reports', 'export', NULL, NOW(), NOW(), true, false),
('reports.manage', 'reports', 'manage', NULL, NOW(), NOW(), true, false),
('reports.configure', 'reports', 'configure', NULL, NOW(), NOW(), true, false),
('analytics.view', 'analytics', 'view', NULL, NOW(), NOW(), true, false),
('analytics.create', 'analytics', 'create', NULL, NOW(), NOW(), true, false),
('analytics.edit', 'analytics', 'edit', NULL, NOW(), NOW(), true, false),
('analytics.delete', 'analytics', 'delete', NULL, NOW(), NOW(), true, false),
('analytics.approve', 'analytics', 'approve', NULL, NOW(), NOW(), true, false),
('analytics.export', 'analytics', 'export', NULL, NOW(), NOW(), true, false),
('analytics.manage', 'analytics', 'manage', NULL, NOW(), NOW(), true, false),
('analytics.configure', 'analytics', 'configure', NULL, NOW(), NOW(), true, false),
('job_board.view', 'job_board', 'view', NULL, NOW(), NOW(), true, false),
('job_board.create', 'job_board', 'create', NULL, NOW(), NOW(), true, false),
('job_board.edit', 'job_board', 'edit', NULL, NOW(), NOW(), true, false),
('job_board.delete', 'job_board', 'delete', NULL, NOW(), NOW(), true, false),
('job_board.approve', 'job_board', 'approve', NULL, NOW(), NOW(), true, false),
('job_board.export', 'job_board', 'export', NULL, NOW(), NOW(), true, false),
('job_board.manage', 'job_board', 'manage', NULL, NOW(), NOW(), true, false),
('job_board.configure', 'job_board', 'configure', NULL, NOW(), NOW(), true, false),
('settings.view', 'settings', 'view', NULL, NOW(), NOW(), true, false),
('settings.create', 'settings', 'create', NULL, NOW(), NOW(), true, false),
('settings.edit', 'settings', 'edit', NULL, NOW(), NOW(), true, false),
('settings.delete', 'settings', 'delete', NULL, NOW(), NOW(), true, false),
('settings.approve', 'settings', 'approve', NULL, NOW(), NOW(), true, false),
('settings.export', 'settings', 'export', NULL, NOW(), NOW(), true, false),
('settings.manage', 'settings', 'manage', NULL, NOW(), NOW(), true, false),
('settings.configure', 'settings', 'configure', NULL, NOW(), NOW(), true, false),
('rbac_users.view', 'rbac_users', 'view', NULL, NOW(), NOW(), true, false),
('rbac_users.create', 'rbac_users', 'create', NULL, NOW(), NOW(), true, false),
('rbac_users.edit', 'rbac_users', 'edit', NULL, NOW(), NOW(), true, false),
('rbac_users.delete', 'rbac_users', 'delete', NULL, NOW(), NOW(), true, false),
('rbac_users.approve', 'rbac_users', 'approve', NULL, NOW(), NOW(), true, false),
('rbac_users.export', 'rbac_users', 'export', NULL, NOW(), NOW(), true, false),
('rbac_users.manage', 'rbac_users', 'manage', NULL, NOW(), NOW(), true, false),
('rbac_users.configure', 'rbac_users', 'configure', NULL, NOW(), NOW(), true, false)
ON CONFLICT (tag_name) DO NOTHING;
-- Bundle holding dashboard / analytics / offers / interviews.view tags
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
SELECT
'analytics_dashboard',
'Dashboard KPI tiles, analytics charts, offers, and interview list',
(
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
FROM app.permission_tags
WHERE is_deleted = false
AND (
module IN ('dashboard', 'analytics', 'offers')
OR tag_name = 'interviews.view'
)
),
true,
NOW(),
NOW(),
true,
false
WHERE NOT EXISTS (
SELECT 1 FROM app.permissions WHERE name = 'analytics_dashboard'
);
-- Append the bundle id to the named system roles (idempotent)
UPDATE app.roles r
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
updated_at = NOW()
FROM app.permissions p
WHERE p.name = 'analytics_dashboard'
AND r.role_name IN (
'system_administrator',
'hr_administrator',
'recruiter',
'hiring_manager',
'department_head',
'ceo'
)
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
-- =============================================================================
-- 1c. Source channels (eleven BRD channels)
-- =============================================================================
INSERT INTO app.source_channels (key, label, is_active, created_at, updated_at)
VALUES
('microsoft_outlook', 'Microsoft Outlook', true, NOW(), NOW()),
('career_portal', 'Career Portal', true, NOW(), NOW()),
('manual_cv_upload', 'Manual CV Upload', true, NOW(), NOW()),
('linkedin', 'LinkedIn', true, NOW(), NOW()),
('indeed', 'Indeed', true, NOW(), NOW()),
('rozee', 'Rozee', true, NOW(), NOW()),
('mustakbil', 'Mustakbil', true, NOW(), NOW()),
('employee_referral', 'Employee Referral', true, NOW(), NOW()),
('recruitment_agency', 'Recruitment Agency', true, NOW(), NOW()),
('campus_hiring', 'Campus Hiring', true, NOW(), NOW()),
('walk_in', 'Walk-in', true, NOW(), NOW())
ON CONFLICT (key) DO NOTHING;
-- =============================================================================
-- Backfills (require new columns/tables from alembic)
-- =============================================================================
-- Source channel from message_to board tags; default Microsoft Outlook
UPDATE app.inbox_messages m
SET source_channel_id = sc.id
FROM app.source_channels sc
WHERE m.source_channel_id IS NULL
AND (
(LOWER(m.message_to) LIKE '%linkedin%' AND sc.key = 'linkedin')
OR (LOWER(m.message_to) LIKE '%indeed%' AND sc.key = 'indeed')
OR (LOWER(m.message_to) LIKE '%rozee%' AND sc.key = 'rozee')
OR (LOWER(m.message_to) LIKE '%mustakbil%' AND sc.key = 'mustakbil')
OR (LOWER(m.message_to) LIKE '%referral%' AND sc.key = 'employee_referral')
OR (LOWER(m.message_to) LIKE '%agency%' AND sc.key = 'recruitment_agency')
OR (LOWER(m.message_to) LIKE '%campus%' AND sc.key = 'campus_hiring')
OR (LOWER(m.message_to) LIKE '%portal%' AND sc.key = 'career_portal')
OR (LOWER(m.message_to) LIKE '%walk%' AND sc.key = 'walk_in')
OR (LOWER(m.message_to) LIKE '%manual%' AND sc.key = 'manual_cv_upload')
);
UPDATE app.inbox_messages m
SET source_channel_id = sc.id
FROM app.source_channels sc
WHERE m.source_channel_id IS NULL
AND sc.key = 'microsoft_outlook';
-- One open stage-transition row per application (cannot invent history)
INSERT INTO app.application_stage_transitions
(id, inbox_id, from_stage, to_stage, valid_from, valid_to, changed_by, actor_kind, change_reason, created_at)
SELECT
gen_random_uuid(),
i.id,
NULL,
m.application_status::text,
COALESCE(i.created_at, NOW()),
NULL,
NULL,
'system',
'backfill',
NOW()
FROM app.inbox i
JOIN app.inbox_messages m ON m.id = i.message_id
WHERE NOT EXISTS (
SELECT 1 FROM app.application_stage_transitions t WHERE t.inbox_id = i.id
);
-- Requisition status from is_active / is_deleted
UPDATE app.job_posts
SET requisition_status = CASE
WHEN is_active AND NOT is_deleted THEN 'open'
ELSE 'closed'
END
WHERE requisition_status IS NULL OR requisition_status = '';

125
backend/offer/app.py Normal file
View File

@ -0,0 +1,125 @@
from datetime import datetime
from fastapi import APIRouter,Depends,Query
from fastapi.responses import JSONResponse
from fastapi import HTTPException
from db_setup import get_session
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from offer.views import Offer
from users.permissions import PermissionTag,require_permission
from dotenv import load_dotenv
load_dotenv()
router = APIRouter()
class OfferCreate(BaseModel):
inbox_id: int
job_post_id: str
candidate_user_id: str
status: str | None = "draft"
base_salary: float | None = None
currency: str | None = None
salary_period: str | None = None
signing_bonus: float | None = None
annual_bonus_pct: float | None = None
equity_units: int | None = None
equity_instrument: str | None = None
start_date: datetime | None = None
expiry_date: datetime | None = None
change_reason: str | None = None
class OfferUpdate(BaseModel):
status: str | None = None
base_salary: float | None = None
currency: str | None = None
salary_period: str | None = None
signing_bonus: float | None = None
annual_bonus_pct: float | None = None
equity_units: int | None = None
equity_instrument: str | None = None
start_date: datetime | None = None
expiry_date: datetime | None = None
sent_at: datetime | None = None
responded_at: datetime | None = None
closed_at: datetime | None = None
issued_by: str | None = None
inbox_id: int | None = None
job_post_id: str | None = None
candidate_user_id: str | None = None
change_reason: str | None = None
class OfferIssue(BaseModel):
change_reason: str | None = None
@router.get("/offers/fetch")
async def fetch_offers(
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)),
offer_id: str | None = Query(None),
status: str | None = Query(None),
inbox_id: int | None = Query(None),
top: int | None = Query(None),
skip: int = Query(0,ge=0),
session: AsyncSession = Depends(get_session),
):
try:
service=Offer(session=session)
data,total=await service.get_offers(offer_id,status,inbox_id,top,skip)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/offers/create")
async def create_offer(
payload: OfferCreate,
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=Offer(session=session)
data=await service.create_offer(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/offers/update")
async def update_offer(
payload: OfferUpdate,
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_EDIT)),
offer_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
try:
service=Offer(session=session)
data=await service.update_offer(offer_id,payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/offers/issue")
async def issue_offer(
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_APPROVE)),
offer_id: str = Query(...),
payload: OfferIssue | None = None,
session: AsyncSession = Depends(get_session),
):
try:
service=Offer(session=session)
data=await service.issue_offer(offer_id,current_user)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))

152
backend/offer/models.py Normal file
View File

@ -0,0 +1,152 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
def _now() -> datetime:
return datetime.now(timezone.utc)
class Offers(SQLModel, table=True):
__tablename__ = "offers"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
inbox_id: int = Field(index=True, foreign_key="inbox.id")
job_post_id: uuid.UUID = Field(foreign_key="job_posts.id")
candidate_user_id: uuid.UUID = Field(foreign_key="users.id")
status: str = Field(default="draft")
base_salary: float = Field(default=0.0)
currency: str = Field(default="USD")
salary_period: str = Field(default="annual")
signing_bonus: float | None = Field(default=None)
annual_bonus_pct: float | None = Field(default=None)
equity_units: int | None = Field(default=None)
equity_instrument: str | None = Field(default=None)
start_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
expiry_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
responded_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
issued_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_offer_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def fetch_offers(
cls,
session: AsyncSession,
*,
status: str | None = None,
inbox_id: int | None = None,
top: int | None = None,
skip: int = 0,
):
statement = select(cls)
if status:
statement = statement.where(cls.status == status)
if inbox_id is not None:
statement = statement.where(cls.inbox_id == int(inbox_id))
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.created_at.desc())
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.scalars().all()), total
@classmethod
async def insert_offer(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_offer_by_id(session, row.id)
@classmethod
async def update_offer(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_offer_by_id(session, record_id)
if not row:
return None
for key, value in fields.items():
setattr(row, key, value)
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def count_by_status(cls, session: AsyncSession, status: str, *, from_date=None, to_date=None):
statement = select(func.count()).select_from(cls).where(cls.status == status)
if from_date is not None:
statement = statement.where(cls.created_at >= from_date)
if to_date is not None:
statement = statement.where(cls.created_at < to_date)
result = await session.execute(statement)
return result.scalar_one()
class OfferStatusHistory(SQLModel, table=True):
__tablename__ = "offer_status_history"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
offer_id: uuid.UUID = Field(index=True, foreign_key="offers.id")
from_status: str | None = Field(default=None)
to_status: str
valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
valid_to: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
changed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
actor_kind: str = Field(default="user")
change_reason: str | None = Field(default=None)
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_offer(cls, session: AsyncSession, offer_id):
uid = cls._as_uuid(offer_id)
if uid is None:
return []
result = await session.execute(
select(cls).where(cls.offer_id == uid).order_by(cls.valid_from.desc())
)
return list(result.scalars().all())
@classmethod
async def insert_history(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
row = cls(**fields)
session.add(row)
if commit:
await session.commit()
await session.refresh(row)
return row
import users.models as _users_models # noqa: E402, F401

4
backend/offer/plugins.py Normal file
View File

@ -0,0 +1,4 @@
def non_validation_values():
fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct",
"equity_units","equity_instrument","start_date","expiry_date")
return fields

View File

@ -0,0 +1,38 @@
def serialize_offer(row) -> dict:
return {
"id": str(row.id) if row.id else None,
"inbox_id": row.inbox_id,
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"candidate_user_id": str(row.candidate_user_id) if row.candidate_user_id else None,
"status": row.status,
"base_salary": row.base_salary,
"currency": row.currency,
"salary_period": row.salary_period,
"signing_bonus": row.signing_bonus,
"annual_bonus_pct": row.annual_bonus_pct,
"equity_units": row.equity_units,
"equity_instrument": row.equity_instrument,
"start_date": row.start_date.isoformat() if row.start_date else None,
"expiry_date": row.expiry_date.isoformat() if row.expiry_date else None,
"sent_at": row.sent_at.isoformat() if row.sent_at else None,
"responded_at": row.responded_at.isoformat() if row.responded_at else None,
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
"issued_by": str(row.issued_by) if row.issued_by else None,
"created_by": str(row.created_by) if row.created_by else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def serialize_offer_history(row) -> dict:
return {
"id": str(row.id) if row.id else None,
"offer_id": str(row.offer_id) if row.offer_id else None,
"from_status": row.from_status,
"to_status": row.to_status,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
"changed_by": str(row.changed_by) if row.changed_by else None,
"actor_kind": row.actor_kind,
"change_reason": row.change_reason,
}

146
backend/offer/views.py Normal file
View File

@ -0,0 +1,146 @@
import uuid
from datetime import datetime,timezone
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from offer.models import Offers,OfferStatusHistory
from offer.serializers import serialize_offer
from offer.plugins import non_validation_values
def _as_uuid(value):
if value in (None,""):
return None
try:
return uuid.UUID(str(value))
except (TypeError,ValueError):
return None
def _user_id(current_user):
if not current_user or not current_user.get("id"):
raise HTTPException(status_code=401,detail="Not authenticated")
uid=_as_uuid(current_user["id"])
if uid is None:
raise HTTPException(status_code=401,detail="Invalid user id")
return uid
class Offer:
def __init__(self,session:AsyncSession):
self.session=session
async def get_offers(self,offer_id=None,status=None,inbox_id=None,top=None,skip=0):
if offer_id is not None:
row=await Offers.get_offer_by_id(self.session,offer_id)
if not row:
raise HTTPException(status_code=404,detail="Offer not found")
return serialize_offer(row),1
rows,total=await Offers.fetch_offers(
self.session,
status=status,
inbox_id=inbox_id,
top=top,
skip=skip or 0,
)
return [serialize_offer(r) for r in rows],total
async def create_offer(self,payload,current_user):
if not payload.get("inbox_id"):
raise HTTPException(status_code=422,detail="inbox_id is required")
job_post_id=_as_uuid(payload.get("job_post_id"))
if job_post_id is None:
raise HTTPException(status_code=422,detail="job_post_id is required")
candidate_user_id=_as_uuid(payload.get("candidate_user_id"))
if candidate_user_id is None:
raise HTTPException(status_code=422,detail="candidate_user_id is required")
created_by=_user_id(current_user)
status=payload.get("status") or "draft"
fields={
"inbox_id": int(payload["inbox_id"]),
"job_post_id": job_post_id,
"candidate_user_id": candidate_user_id,
"created_by": created_by,
"status": status,
}
for key in non_validation_values():
if key in payload and payload[key] is not None:
fields[key]=payload[key]
row=await Offers.insert_offer(self.session,fields)
history_data={
"offer_id": row.id,
"from_status": None,
"to_status": status,
"changed_by": created_by,
"actor_kind": "user",
"change_reason": payload.get("change_reason"),
}
await OfferStatusHistory.insert_history(self.session,history_data)
return serialize_offer(row)
async def update_offer(self,offer_id,payload,current_user):
row=await Offers.get_offer_by_id(self.session,offer_id)
if not row:
raise HTTPException(status_code=404,detail="Offer not found")
changed_by=_user_id(current_user)
fields={}
for key in (
"status","base_salary","currency","salary_period","signing_bonus","annual_bonus_pct",
"equity_units","equity_instrument","start_date","expiry_date","sent_at",
"responded_at","closed_at","issued_by","inbox_id","job_post_id","candidate_user_id",
):
if key not in payload:
continue
value=payload[key]
if key in ("job_post_id","candidate_user_id","issued_by") and value is not None:
value=_as_uuid(value)
if value is None:
raise HTTPException(status_code=422,detail=f"Invalid {key}")
fields[key]=value
if not fields:
raise HTTPException(status_code=400,detail="No fields to update")
new_status=fields.get("status")
if new_status is not None and new_status!=row.status:
await OfferStatusHistory.insert_history(self.session,{
"offer_id": row.id,
"from_status": row.status,
"to_status": new_status,
"changed_by": changed_by,
"actor_kind": "user",
"change_reason": payload.get("change_reason"),
},commit=False)
updated=await Offers.update_offer(self.session,offer_id,fields)
if not updated:
raise HTTPException(status_code=404,detail="Offer not found")
return serialize_offer(updated)
async def issue_offer(self,offer_id,current_user):
row=await Offers.get_offer_by_id(self.session,offer_id)
if not row:
raise HTTPException(status_code=404,detail="Offer not found")
issued_by=_user_id(current_user)
now=datetime.now(timezone.utc)
from_status=row.status
fields={"issued_by": issued_by,"sent_at": now}
to_status=from_status
if from_status=="draft":
fields["status"]="sent"
to_status="sent"
updated=await Offers.update_offer(self.session,offer_id,fields)
if not updated:
raise HTTPException(status_code=404,detail="Offer not found")
history_data={
"offer_id": updated.id,
"from_status": from_status,
"to_status": to_status,
"changed_by": issued_by,
"actor_kind": "user",
"change_reason": "issued",
}
await OfferStatusHistory.insert_history(self.session,history_data)
return serialize_offer(updated)

View File

@ -38,3 +38,9 @@ redis>=5.0,<6.0 # DLQ middleware (taskiq_management/middleware.py) as
# --- LLM -------------------------------------------------------------------
openai==2.53.0 # AsyncOpenAI client in llm_setup.py
langgraph==1.2.10 # StateGraph agent framework in agent/agent_setup.py
# --- ATS scoring -----------------------------------------------------------
# The bulk-ats scoring engine (app.services.pdf / llm / scoring) is installed
# editable from the repo root — run once per environment:
# pip install -e ..
# Its dependencies are already satisfied by the pins above.

View File

@ -1,11 +1,15 @@
from datetime import datetime
from datetime import datetime, timezone
from enum import Enum
from sqlalchemy import Column, UniqueConstraint, func, or_
from sqlalchemy import Column, DateTime, UniqueConstraint, func, or_
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select
def _now() -> datetime:
return datetime.now(timezone.utc)
class EnumRoles(str, Enum):
"""Canonical keys for the eight seeded system roles. `Roles.role_name` is a varchar."""
@ -30,8 +34,8 @@ class PermissionTags(SQLModel, table=True):
module: str = Field(max_length=32, nullable=False, index=True)
action: str = Field(max_length=32, nullable=False)
description: str | None = Field(default=None)
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_active: bool = Field(default=True)
is_deleted: bool = Field(default=False)
@ -104,8 +108,8 @@ class Permissions(SQLModel, table=True):
description: str | None = Field(default=None)
permission_tags: list | None = Field(default=None, sa_column=Column(JSONB))
is_system: bool = Field(default=False)
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_active: bool = Field(default=True)
is_deleted: bool = Field(default=False)
@ -182,7 +186,7 @@ class Permissions(SQLModel, table=True):
return None
for key, value in fields.items():
setattr(row, key, value)
row.updated_at = datetime.now()
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
@ -195,7 +199,7 @@ class Permissions(SQLModel, table=True):
return None
row.is_deleted = True
row.is_active = False
row.updated_at = datetime.now()
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
@ -210,8 +214,8 @@ class Roles(SQLModel, table=True):
description: str | None = Field(default=None)
permissions: list | None = Field(default=None, sa_column=Column(JSONB))
is_system: bool = Field(default=False)
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_active: bool = Field(default=True)
is_deleted: bool = Field(default=False)
@ -280,7 +284,7 @@ class Roles(SQLModel, table=True):
return None
for key, value in fields.items():
setattr(row, key, value)
row.updated_at = datetime.now()
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
@ -293,7 +297,7 @@ class Roles(SQLModel, table=True):
return None
row.is_deleted = True
row.is_active = False
row.updated_at = datetime.now()
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)

View File

@ -1,8 +1,8 @@
import uuid
from datetime import datetime
from datetime import datetime, timezone
from typing import TYPE_CHECKING,List,Optional
from sqlalchemy import func, or_
from sqlalchemy import DateTime, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import Field, Relationship, SQLModel, select
@ -14,6 +14,11 @@ if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this modul
from inbox.models import Inbox
from job.candidate.models import Feedback, Notes
def _now() -> datetime:
return datetime.now(timezone.utc)
class Users(SQLModel, table=True):
__tablename__ = "users"
@ -27,9 +32,11 @@ class Users(SQLModel, table=True):
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
# user row once per post. Without an explicit strategy the default is a lazy load,
# which raises MissingGreenlet the moment anything touches it under asyncio.
# foreign_keys must match the other side: job_posts.current_recruiter_id is a
# second FK into this table, so this relation has to say it means created_by.
job_posts: List[JobPosts] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin"},
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
)
inbox: List["Inbox"] = Relationship(
back_populates="user",
@ -50,8 +57,8 @@ class Users(SQLModel, table=True):
password: str
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_active: bool = Field(default=False)
is_deleted: bool = Field(default=False)
@ -140,7 +147,7 @@ class Users(SQLModel, table=True):
return None
for key, value in fields.items():
setattr(user, key, value)
user.updated_at = datetime.now()
user.updated_at = _now()
session.add(user)
await session.commit()
await session.refresh(user)
@ -153,7 +160,7 @@ class Users(SQLModel, table=True):
return None
user.is_deleted = True
user.is_active = False
user.updated_at = datetime.now()
user.updated_at = _now()
session.add(user)
await session.commit()
await session.refresh(user)

View File

@ -0,0 +1,8 @@
{
"hash": "4acff9bb",
"configHash": "53a8a5ec",
"lockfileHash": "fac4afd8",
"browserHash": "5b5a9255",
"optimized": {},
"chunks": {}
}

View File

@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@ -0,0 +1,14 @@
import { request } from '../lib/apiClient'
/**
* Activity feed backend/job/app.py `/activity/fetch` global mode (top/skip).
* Permissioned with CANDIDATES_VIEW.
*/
export function feed({ top = 8, skip = 0 } = {}) {
return request('/activity/fetch', { params: { top, skip } })
}
export function listByInbox(inboxId) {
return request('/activity/fetch', { params: { inbox_id: inboxId } })
}

View File

@ -0,0 +1,63 @@
import { request } from '../lib/apiClient'
/**
* Dashboard analytics aggregates backend/analytics/app.py.
* Permissioned with require_permission(ANALYTICS_VIEW).
*/
export function kpis({ fromDate, toDate, department, recruiterId } = {}) {
return request('/analytics/kpis/fetch', {
params: {
from_date: fromDate,
to_date: toDate,
department,
recruiter_id: recruiterId,
},
})
}
export function funnel({ fromDate, toDate, department, recruiterId } = {}) {
return request('/analytics/funnel/fetch', {
params: {
from_date: fromDate,
to_date: toDate,
department,
recruiter_id: recruiterId,
},
})
}
export function hiringTrend({ months = 7, fromDate, toDate, department, recruiterId } = {}) {
return request('/analytics/hiring-trend/fetch', {
params: {
months,
from_date: fromDate,
to_date: toDate,
department,
recruiter_id: recruiterId,
},
})
}
export function sourcePerformance({ fromDate, toDate, department, recruiterId } = {}) {
return request('/analytics/source-performance/fetch', {
params: {
from_date: fromDate,
to_date: toDate,
department,
recruiter_id: recruiterId,
},
})
}
export function recruiterPerformance({ top = 5, fromDate, toDate, department, recruiterId } = {}) {
return request('/analytics/recruiter-performance/fetch', {
params: {
top,
from_date: fromDate,
to_date: toDate,
department,
recruiter_id: recruiterId,
},
})
}

View File

@ -1,5 +1,92 @@
/* ============================================================
candidates.js candidate endpoints (backend/job/app.py).
Two data families share this module:
- ATS scoring (persisted `candidates` table): listJobs, listCandidates,
getCandidate, scoreUploads, scoreInbox, toCandidateView.
- Candidate profiles (inbox -> users -> roles join): list, getByUserId,
toRows.
Same conventions as inbox.js: one named export per endpoint, no hooks,
camelCase params mapped to snake_case at the call boundary, and every
function returns the parsed {data, total, status_code} envelope.
============================================================ */
import { request } from '../lib/apiClient'
/** Active job posts for pickers. Needs job_board.view OR candidates.view. */
export function listJobs() {
return request('/job/fetch')
}
/**
* Persisted scoring leaderboard. Needs candidates.view.
* Omit jobId for the whole pool across jobs; rows are ordered completed-by-
* score-desc, then failed rows.
*/
export function listCandidates({ jobId } = {}) {
return request('/candidate/scored/fetch', { params: { job_id: jobId } })
}
/** One scored candidate row by id. Needs candidates.view. 404s on unknown ids. */
export function getCandidate(candidateId) {
return request('/candidate/fetch_by_id', { params: { candidate_id: candidateId } })
}
/**
* Score uploaded CV PDFs against a job post. Needs candidates.create.
* Multipart: unreadable/oversized/non-PDF files come back as rows with
* status "failed" instead of failing the batch. Re-scoring identical bytes
* against the same job updates the existing row (no duplicates).
*/
export function scoreUploads(jobId, files) {
const form = new FormData()
form.append('job_id', jobId)
for (const file of files) form.append('files', file, file.name)
return request('/candidate/score', { method: 'POST', body: form })
}
/**
* Score the decoded attachments of inbox messages against a job post.
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`
* field the inbox list returns), not Graph message ids.
*/
export function scoreInbox(jobId, messageIds) {
return request('/candidate/score_inbox', {
method: 'POST',
body: { job_id: jobId, message_ids: messageIds },
})
}
/**
* Shared snake_case camelCase view-model mapper for candidate rows, so the
* three candidate screens agree on field names. Fields the backend does not
* store (email, phone, stage, education) are deliberately absent screens
* hide those affordances rather than render placeholders (Inbox precedent).
*/
export function toCandidateView(row) {
const name = row.candidate_name || row.filename || 'Unknown'
return {
id: row.id,
jobId: row.job_id,
name,
filename: row.filename,
source: row.source, // 'upload' | 'inbox'
currentTitle: row.job_title ?? null,
currentCompany: row.current_company ?? null,
experience: row.years_experience ?? null,
aiScore: row.match_score ?? null,
matchedSkills: Array.isArray(row.matched_keywords) ? row.matched_keywords : [],
missingSkills: Array.isArray(row.missing_keywords) ? row.missing_keywords : [],
critique: row.summary_critique ?? null,
scoringStatus: row.status, // 'completed' | 'failed'
errorCode: row.error_code ?? null,
errorMessage: row.error_message ?? null,
applied: row.created_at ? new Date(row.created_at) : null,
inboxMessageId: row.inbox_message_id ?? null,
}
}
/**
* Candidate profiles the `inbox -> users -> roles` join, restricted server-side
* to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile).

View File

@ -0,0 +1,23 @@
import { request } from '../lib/apiClient'
/**
* Interviews backend/job/app.py `/interview/*`.
* Range mode (from_date / to_date / status / top) is additive; per-inbox
* fetch still works when inbox_id is set.
*/
export function listRange({ fromDate, toDate, status, top, skip } = {}) {
return request('/interview/fetch', {
params: {
from_date: fromDate,
to_date: toDate,
status,
top,
skip,
},
})
}
export function listByInbox(inboxId) {
return request('/interview/fetch', { params: { inbox_id: inboxId } })
}

View File

@ -0,0 +1,38 @@
import { request } from '../lib/apiClient'
/**
* Offers backend/offer/app.py.
* Permissioned with OFFERS_VIEW / OFFERS_CREATE / OFFERS_EDIT / OFFERS_APPROVE.
*/
export function list({ offerId, status, inboxId, top, skip } = {}) {
return request('/offers/fetch', {
params: {
offer_id: offerId,
status,
inbox_id: inboxId,
top,
skip,
},
})
}
export function create(body) {
return request('/offers/create', { method: 'POST', body })
}
export function update(offerId, body) {
return request('/offers/update', {
method: 'PATCH',
params: { offer_id: offerId },
body,
})
}
export function issue(offerId, body = {}) {
return request('/offers/issue', {
method: 'POST',
params: { offer_id: offerId },
body,
})
}

View File

@ -16,11 +16,6 @@ export const qk = {
permissions: () => ['roles', 'permissions'],
tags: () => ['roles', 'permission-tags'],
},
candidates: {
all: () => ['candidates'],
list: (p = {}) => ['candidates', 'list', p],
detail: (userId) => ['candidates', 'detail', userId],
},
mailbox: {
all: () => ['mailbox'],
messages: () => ['mailbox', 'messages'],
@ -32,6 +27,26 @@ export const qk = {
all: () => ['jobPosts'],
list: (p = {}) => ['jobPosts', 'list', p],
},
jobs: {
all: () => ['jobs'],
list: () => ['jobs', 'list'],
},
candidates: {
all: () => ['candidates'],
list: (p = {}) => ['candidates', 'list', p],
detail: (id) => ['candidates', 'detail', id],
},
analytics: {
all: () => ['analytics'],
kpis: (p = {}) => ['analytics', 'kpis', p],
funnel: (p = {}) => ['analytics', 'funnel', p],
trend: (p = {}) => ['analytics', 'trend', p],
sources: (p = {}) => ['analytics', 'sources', p],
recruiters: (p = {}) => ['analytics', 'recruiters', p],
},
offers: { all: () => ['offers'], list: (p = {}) => ['offers', 'list', p] },
interviews: { all: () => ['interviews'], range: (p = {}) => ['interviews', 'range', p] },
activity: { all: () => ['activity'], feed: (p = {}) => ['activity', 'feed', p] },
// --- seed-backed buckets ---
// These are not "server state" — the cache IS the store for them, so every

View File

@ -1,4 +1,4 @@
/* The 8-tab candidate profile modal, split out of Candidates.jsx it was the
/* The 8-tab candidate profile modal, split out of Candidates.jsx it was the
single largest block in js/candidates.js and deserves its own file.
TWO DATA MODES, selected by whether the caller passes a `userId`:
@ -130,6 +130,16 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'),
})
// Score the candidate's inbox CV against the assigned job with the ATS engine.
// Needs both an assigned job (what to score against) and a message (whose
// attachment to score); the refetch lands the new ai_score in this modal.
const canScoreAts = isLive && Boolean(live?.assigned_job_post_id && live?.message_id)
const scoreAts = useProfileWrite({
userId: c.userId,
mutationFn: () => candidatesApi.scoreInbox(live.assigned_job_post_id, [live.message_id]),
success: 'CV scored against the assigned job',
})
const title = live?.job_title || c.currentTitle
const company = live?.currentCompany || c.currentCompany
@ -171,6 +181,15 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
>
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
</button>
{canScoreAts && (
<button
className="btn btn-secondary"
disabled={scoreAts.isPending}
onClick={() => scoreAts.mutate()}
>
<Icon name="sparkles" /> {scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}
</button>
)}
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
</button>
@ -194,7 +213,7 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
</div>
</div>
<div style={{ textAlign: 'center' }}>
<ScoreChip score={c.aiScore} />
<ScoreChip score={live?.ai_score ?? c.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
</div>
</div>

View File

@ -1,39 +1,50 @@
/* ============================================================
Candidates the largest screen in the app: a 14-facet filter panel, a
composite relevance sort, a multi-select bulk bar, favourites, a
recently-viewed strip, the ATS-match modal and the 8-tab profile
(CandidateProfile.jsx).
/* ============================================================
Candidates the scored-candidate pool, on live backend data.
Uses the headless `useDataTable` rather than <DataTable/>, because the
selection column needs to render against a Set this component owns.
Rows come from GET /candidate/fetch (all jobs) via the shared
toCandidateView mapper. Facets, columns and actions that had no backing
column (stage, recruiter, notice period, favourites) are gone rather than
rendered as placeholders the Inbox screen set that precedent. Adding
candidates happens through CV Import (real scoring), not a manual form.
============================================================ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Pagination, useDataTable } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, FieldError, Icon, ProgressBar, ScoreChip } from '../ui/primitives'
import { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useFormState } from '../components/AuthLayout'
import CandidateProfile from './CandidateProfile'
import CandidateProfile from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import { persist, seedQuery, useSeedMutation } from '../data/seedQueries'
import {
atsRecommendationClass, avatarColor, departments, educationLevels, getJob,
initials as initialsOf, int, locations, skillsPool, sources, stages, TODAY,
} from '../data/seed'
import { useFormState } from '../components/AuthLayout'
import { persist } from '../data/seedQueries'
import { atsRecommendationClass, avatarColor, initials as initialsOf, sources, stages } from '../data/seed'
const STAGE_ORDER = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
const EXP_BUCKETS = ['0-2', '3-5', '6-9', '10+']
const ATS_BANDS = ['85+', '70-84', '<70']
const INTERVIEW_STATES = ['Not Scheduled', 'Scheduled', 'Completed']
const NOTICE = ['Immediate', '2 weeks', '1 month', '2 months', '3 months']
const AVAILABILITY = ['Immediate', '2 weeks', '1 month', 'Passive']
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
const EMPTY_FILTERS = { job: '', skill: '', source: '', ats: '', status: '' }
async function fetchCandidates() {
const res = await candidatesApi.listCandidates()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(candidatesApi.toCandidateView)
}
async function fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title }))
}
function recommendationOf(c) {
if (c.aiScore == null) return 'Weak Match'
return c.aiScore >= 82 ? 'Strong Match' : c.aiScore >= 65 ? 'Potential Match' : 'Weak Match'
}
/* Client-side guard only the route has no size cap of its own, so this just
stops an obviously wrong file from being read into memory and posted. */
@ -63,43 +74,46 @@ const REFERRAL_RE = new RegExp(
*/
const referralValue = (raw) => (raw || '').trim().toLowerCase()
const EMPTY_FILTERS = {
job: '', skill: '', dept: '', location: '', exp: '', edu: '', recruiter: '',
manager: '', source: '', ats: '', stage: '', interview: '', notice: '', availability: '',
}
export default function Candidates() {
const { toast } = useToast()
const qc = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const { data: managers = [] } = useQuery(seedQuery('managers'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const candidatesQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchCandidates })
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data])
const jobsById = useMemo(
() => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])),
[jobsQuery.data],
)
const { data: recentlyViewed = [] } = useQuery({
queryKey: qk.seed.recentlyViewed(),
queryFn: async () => [],
staleTime: Infinity,
gcTime: Infinity,
})
const updateCandidates = useSeedMutation('candidates')
const [q, setQ] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [sortMode, setSortMode] = useState('relevance')
const [selected, setSelected] = useState(() => new Set())
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
const [adding, setAdding] = useState(false)
const [bulkAssigning, setBulkAssigning] = useState(false)
/** ATS + matched-skill ratio + recency. Verbatim from js/candidates.js:14-20. */
const jobTitleOf = useCallback(
(c) => jobsById[c.jobId]?.title ?? '—',
[jobsById],
)
/** ATS score + matched-skill ratio + recency same shape as before, but every
input is now real: matched/missing come from the model, applied from the DB. */
const relevance = useCallback((c) => {
const req = (getJob(c.jobId) || {}).skills || []
const skillRatio = req.length ? c.matchedSkills.length / req.length : 0.5
const recency = 1 - Math.min(1, (TODAY - c.applied) / (90 * 864e5))
if (c.aiScore == null) return 0
const total = c.matchedSkills.length + c.missingSkills.length
const skillRatio = total ? c.matchedSkills.length / total : 0.5
const recency = c.applied ? 1 - Math.min(1, (Date.now() - c.applied) / (90 * 864e5)) : 0.5
return Math.round(c.aiScore * 0.7 + skillRatio * 20 + recency * 10)
}, [])
@ -115,7 +129,7 @@ export default function Candidates() {
[qc],
)
// Deep links from global search, dashboard, pipeline, calendar, interviews
// Deep links from Talent Pool, global search, dashboard
useEffect(() => {
const st = location.state
if (!st) return
@ -126,124 +140,62 @@ export default function Candidates() {
}
}, [location.state, candidates, openProfile])
const jobTitles = useMemo(() => [...new Set(candidates.map((c) => c.jobTitle))], [candidates])
const skillOptions = useMemo(() => {
const set = new Set()
for (const c of candidates) for (const s of c.matchedSkills) set.add(s)
return [...set].sort((a, b) => a.localeCompare(b)).slice(0, 40)
}, [candidates])
const jobOptions = useMemo(
() => (jobsQuery.data ?? []).map((j) => j.title),
[jobsQuery.data],
)
const rows = useMemo(() => {
const f = filters
let list = candidates.filter((c) => {
if (f.job && c.jobTitle !== f.job) return false
if (f.skill && !c.skills.includes(f.skill)) return false
if (f.dept && c.department !== f.dept) return false
if (f.location && c.location !== f.location) return false
if (f.exp === '0-2' && c.experience > 2) return false
if (f.exp === '3-5' && (c.experience < 3 || c.experience > 5)) return false
if (f.exp === '6-9' && (c.experience < 6 || c.experience > 9)) return false
if (f.exp === '10+' && c.experience < 10) return false
if (f.edu && c.education !== f.edu) return false
if (f.recruiter && c.recruiter !== f.recruiter) return false
if (f.manager) {
const job = getJob(c.jobId)
if (!job || job.manager !== f.manager) return false
}
if (f.job && jobTitleOf(c) !== f.job) return false
if (f.skill && !c.matchedSkills.includes(f.skill)) return false
if (f.source && c.source !== f.source) return false
if (f.ats === '85+' && c.aiScore < 85) return false
if (f.ats === '70-84' && (c.aiScore < 70 || c.aiScore > 84)) return false
if (f.ats === '<70' && c.aiScore >= 70) return false
if (f.stage && c.stage !== f.stage) return false
if (f.interview && c.interviewStatus !== f.interview) return false
if (f.notice && c.noticePeriod !== f.notice) return false
if (f.availability && c.availability !== f.availability) return false
if (f.status === 'Scored' && c.scoringStatus !== 'completed') return false
if (f.status === 'Failed' && c.scoringStatus !== 'failed') return false
if (f.ats === '85+' && (c.aiScore == null || c.aiScore < 85)) return false
if (f.ats === '70-84' && (c.aiScore == null || c.aiScore < 70 || c.aiScore > 84)) return false
if (f.ats === '<70' && (c.aiScore == null || c.aiScore >= 70)) return false
if (q) {
const term = q.toLowerCase()
const hay = (c.name + c.email + c.jobTitle + c.currentCompany + c.recruiter + c.skills.join(' ')).toLowerCase()
const hay = [
c.name, c.filename, c.currentTitle ?? '', c.currentCompany ?? '',
c.matchedSkills.join(' '),
].join(' ').toLowerCase()
if (!hay.includes(term)) return false
}
return true
})
if (sortMode === 'relevance') list = [...list].sort((a, b) => relevance(b) - relevance(a))
else if (sortMode === 'ats') list = [...list].sort((a, b) => b.aiScore - a.aiScore)
else if (sortMode === 'recent') list = [...list].sort((a, b) => b.applied - a.applied)
else if (sortMode === 'ats') list = [...list].sort((a, b) => (b.aiScore ?? -1) - (a.aiScore ?? -1))
else if (sortMode === 'recent') list = [...list].sort((a, b) => (b.applied ?? 0) - (a.applied ?? 0))
else if (sortMode === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name))
return list
}, [candidates, filters, q, sortMode, relevance])
}, [candidates, filters, q, sortMode, relevance, jobTitleOf])
const columns = useMemo(
() => [
{ key: '_sel', label: '' },
{ key: 'name', label: 'Candidate', sortable: true },
{ key: 'jobTitle', label: 'Applied Job', sortable: true },
{ key: '_job', label: 'Scored For', sortable: true, sortValue: jobTitleOf },
{ key: 'experience', label: 'Exp', sortable: true, align: 'center' },
{ key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: relevance },
{ key: 'stage', label: 'Stage', sortable: true },
{ key: 'scoringStatus', label: 'Status', sortable: true },
{ key: 'aiScore', label: 'ATS', sortable: true, align: 'center' },
{ key: 'availability', label: 'Availability' },
{ key: 'applied', label: 'Added', sortable: true },
{ key: '_a', label: 'Actions', align: 'right' },
],
[relevance],
[relevance, jobTitleOf],
)
const t = useDataTable({ columns, rows, pageSize: 10 })
function toggleSelect(id) {
setSelected((s) => {
const next = new Set(s)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleFav(c) {
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x)))
setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p))
toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success')
}
function advance(c) {
const i = STAGE_ORDER.indexOf(c.stage)
if (i === -1 || i >= STAGE_ORDER.length - 1) {
toast(`${c.name} cannot be advanced further`, 'warning')
return
}
const stage = STAGE_ORDER[i + 1]
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x)))
toast(`${c.name} moved to ${stage}`, 'success')
}
function bulk(action) {
const ids = [...selected]
if (!ids.length) return
if (action === 'email') {
toast(`Bulk email drafted to ${ids.length} candidates`, 'success')
setSelected(new Set())
return
}
if (action === 'assign') {
setBulkAssigning(true)
return
}
if (action === 'advance') {
updateCandidates((cs) =>
cs.map((c) => {
if (!selected.has(c.id)) return c
const i = STAGE_ORDER.indexOf(c.stage)
if (i === -1 || i >= STAGE_ORDER.length - 1) return c
const stage = STAGE_ORDER[i + 1]
return { ...c, stage, status: stage }
}),
)
toast(`${ids.length} candidates advanced`, 'success')
}
if (action === 'reject') {
updateCandidates((cs) =>
cs.map((c) => (selected.has(c.id) ? { ...c, stage: 'Rejected', status: 'Rejected' } : c)),
)
toast(`${ids.length} candidates rejected`, 'warning')
}
setSelected(new Set())
}
const recentChips = recentlyViewed
.slice(0, 6)
.map((id) => candidates.find((c) => c.id === id))
@ -251,6 +203,14 @@ export default function Candidates() {
const setFilter = (k, v) => setFilters((f) => ({ ...f, [k]: v }))
function openAts(c) {
if (c.scoringStatus !== 'completed') {
toast('This CV could not be scored — no match analysis available', 'info')
return
}
setAtsFor(c)
}
return (
<div className="page">
<div className="page-head">
@ -261,12 +221,12 @@ export default function Candidates() {
</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={() => toast('Search saved', 'success')}>
<Icon name="bookmark" /> Save Search
</button>
<button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}>
<Icon name="download" /> Export
</button>
<button className="btn btn-secondary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Import CVs
</button>
<button className="btn btn-primary" onClick={() => setAdding(true)}>
<Icon name="plus" /> Add Candidate
</button>
@ -278,25 +238,12 @@ export default function Candidates() {
<span className="text-muted text-sm fw-600">Recently viewed:</span>
{recentChips.map((c) => (
<button key={c.id} className="prompt-chip" style={{ padding: '5px 10px' }} onClick={() => openProfile(c)}>
<Avatar name={c.name} initials={c.initials} color={c.color} /> {c.name.split(' ')[0]}
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} /> {c.name.split(' ')[0]}
</button>
))}
</div>
)}
{selected.size > 0 && (
<div className="bulk-bar" style={{ display: 'flex' }}>
<span className="checkbox on"><Icon name="check" /></span>
<span className="fw-600">{selected.size} selected</span>
<div style={{ flex: 1 }} />
<button className="btn btn-sm" onClick={() => bulk('email')}><Icon name="mail" /> Bulk Email</button>
<button className="btn btn-sm" onClick={() => bulk('assign')}><Icon name="users" /> Assign</button>
<button className="btn btn-sm" onClick={() => bulk('advance')}><Icon name="check" /> Advance</button>
<button className="btn btn-sm" onClick={() => bulk('reject')}><Icon name="x" /> Reject</button>
<button className="btn btn-sm" onClick={() => setSelected(new Set())}><Icon name="x" /> Clear</button>
</div>
)}
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
@ -322,156 +269,150 @@ export default function Candidates() {
className="filter-panel"
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
>
<Facet label="Job" value={filters.job} onChange={(v) => setFilter('job', v)} any="Any Job" options={jobTitles} />
<Facet label="Skill" value={filters.skill} onChange={(v) => setFilter('skill', v)} any="Any Skill" options={skillsPool} />
<Facet label="Department" value={filters.dept} onChange={(v) => setFilter('dept', v)} any="Any Dept" options={departments} />
<Facet label="Location" value={filters.location} onChange={(v) => setFilter('location', v)} any="Any Location" options={locations} />
<Facet label="Experience" value={filters.exp} onChange={(v) => setFilter('exp', v)} any="Any Exp" options={EXP_BUCKETS} />
<Facet label="Education" value={filters.edu} onChange={(v) => setFilter('edu', v)} any="Any" options={educationLevels} />
<Facet label="Recruiter" value={filters.recruiter} onChange={(v) => setFilter('recruiter', v)} any="Any Recruiter" options={recruiters.map((r) => r.name)} />
<Facet label="Hiring Manager" value={filters.manager} onChange={(v) => setFilter('manager', v)} any="Any Manager" options={managers.map((m) => m.name)} />
<Facet label="Source" value={filters.source} onChange={(v) => setFilter('source', v)} any="Any Source" options={sources} />
<Facet label="Job" value={filters.job} onChange={(v) => setFilter('job', v)} any="Any Job" options={jobOptions} />
<Facet label="Matched Skill" value={filters.skill} onChange={(v) => setFilter('skill', v)} any="Any Skill" options={skillOptions} />
<Facet label="Source" value={filters.source} onChange={(v) => setFilter('source', v)} any="Any Source" options={['upload', 'inbox']} labels={SOURCE_LABEL} />
<Facet label="ATS Score" value={filters.ats} onChange={(v) => setFilter('ats', v)} any="Any Score" options={ATS_BANDS} />
<Facet label="Pipeline Stage" value={filters.stage} onChange={(v) => setFilter('stage', v)} any="Any Stage" options={stages} />
<Facet label="Interview Status" value={filters.interview} onChange={(v) => setFilter('interview', v)} any="Any" options={INTERVIEW_STATES} />
<Facet label="Notice Period" value={filters.notice} onChange={(v) => setFilter('notice', v)} any="Any" options={NOTICE} />
<Facet label="Availability" value={filters.availability} onChange={(v) => setFilter('availability', v)} any="Any" options={AVAILABILITY} />
<Facet label="Status" value={filters.status} onChange={(v) => setFilter('status', v)} any="Any Status" options={['Scored', 'Failed']} />
</div>
)}
</div>
<div className="dt">
<div className="table-wrap">
<table className="data">
<thead>
<tr>
{columns.map((c) => {
const isSorted = t.sort.key === c.key
const cls = [
c.sortable ? 'sortable' : '',
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
].filter(Boolean).join(' ')
return (
<th
key={c.key}
className={cls}
style={{ textAlign: c.align || 'left' }}
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
>
{c.label}
{c.sortable && (
<span className="sort-ind">{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}</span>
)}
</th>
)
})}
</tr>
</thead>
<tbody>
{t.pageRows.length === 0 ? (
<tr><td colSpan={columns.length}><EmptyState /></td></tr>
) : (
t.pageRows.map((c) => (
<tr key={c.id}>
<td>
<span
className={`checkbox ${selected.has(c.id) ? 'on' : ''}`}
onClick={() => toggleSelect(c.id)}
role="checkbox"
aria-checked={selected.has(c.id)}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSelect(c.id) } }}
{candidatesQuery.isPending && (
<div className="card-body">
<EmptyState icon="users" title="Loading…">Fetching candidates from the server.</EmptyState>
</div>
)}
{candidatesQuery.isError && (
<div className="card-body">
<EmptyState icon="users" title="Couldnt load candidates">
{friendlyAuthError(candidatesQuery.error, 'Request failed')}
</EmptyState>
</div>
)}
{candidatesQuery.isSuccess && (
<div className="dt">
<div className="table-wrap">
<table className="data">
<thead>
<tr>
{columns.map((c) => {
const isSorted = t.sort.key === c.key
const cls = [
c.sortable ? 'sortable' : '',
isSorted ? (t.sort.dir === 1 ? 'sorted-asc' : 'sorted-desc') : '',
].filter(Boolean).join(' ')
return (
<th
key={c.key}
className={cls}
style={{ textAlign: c.align || 'left' }}
onClick={c.sortable ? () => t.toggleSort(c.key) : undefined}
>
<Icon name="check" />
</span>
</td>
<td>
<div className="user-cell">
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div>
<div className="cell-primary">
{c.name}{' '}
{c.favorite && (
<span className="star-btn on" style={{ display: 'inline' }}><Icon name="star" /></span>
)}
</div>
<div className="cell-sub">{c.currentTitle} · {c.location}</div>
</div>
</div>
</td>
<td>
<div className="text-sm">{c.jobTitle}</div>
<div className="cell-sub">{c.department}</div>
</td>
<td style={{ textAlign: 'center' }}><b>{c.experience}</b>y</td>
<td style={{ textAlign: 'center' }}>
<span className={`badge ${atsRecommendationClass(c.recommendation)} badge-plain`}>
{relevance(c)}%
</span>
</td>
<td><Badge>{c.stage}</Badge></td>
<td style={{ textAlign: 'center' }}>
<span style={{ cursor: 'pointer' }} onClick={() => setAtsFor(c)}>
<ScoreChip score={c.aiScore} />
</span>
</td>
<td>
<span className="text-sm">{c.availability}</span>
<div className="cell-sub">{c.noticePeriod} notice</div>
</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className={`act-btn star-btn ${c.favorite ? 'on' : ''}`} data-tip="Favorite" onClick={() => toggleFav(c)}>
<Icon name="star" />
</button>
<button className="act-btn" data-tip="ATS Match" onClick={() => setAtsFor(c)}><Icon name="target" /></button>
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="Advance" onClick={() => advance(c)}><Icon name="check" /></button>
</div>
{c.label}
{c.sortable && (
<span className="sort-ind">{isSorted ? (t.sort.dir === 1 ? '▲' : '▼') : '⇅'}</span>
)}
</th>
)
})}
</tr>
</thead>
<tbody>
{t.pageRows.length === 0 ? (
<tr>
<td colSpan={columns.length}>
<EmptyState title="No candidates yet">
Score resumes in CV Import to fill this table.
</EmptyState>
</td>
</tr>
))
)}
</tbody>
</table>
) : (
t.pageRows.map((c) => (
<tr key={c.id}>
<td>
<div className="user-cell">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} />
<div>
<div className="cell-primary">{c.name}</div>
<div className="cell-sub">
{c.currentTitle ?? c.filename}
{c.currentCompany ? ` · ${c.currentCompany}` : ''}
</div>
</div>
</div>
</td>
<td>
<div className="text-sm">{jobTitleOf(c)}</div>
<div className="cell-sub">{SOURCE_LABEL[c.source] ?? c.source}</div>
</td>
<td style={{ textAlign: 'center' }}>
{c.experience != null ? <><b>{c.experience}</b>y</> : '—'}
</td>
<td style={{ textAlign: 'center' }}>
{c.scoringStatus === 'completed' ? (
<span className={`badge ${atsRecommendationClass(recommendationOf(c))} badge-plain`}>
{relevance(c)}%
</span>
) : '—'}
</td>
<td>
{c.scoringStatus === 'completed'
? <Badge className="b-green">Scored</Badge>
: <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>}
</td>
<td style={{ textAlign: 'center' }}>
{c.aiScore != null ? (
<span style={{ cursor: 'pointer' }} onClick={() => openAts(c)}>
<ScoreChip score={c.aiScore} />
</span>
) : '—'}
</td>
<td>
<span className="text-sm">
{c.applied ? c.applied.toLocaleDateString() : '—'}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className="act-btn" data-tip="ATS Match" onClick={() => openAts(c)}><Icon name="target" /></button>
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination {...t} />
</div>
<Pagination {...t} />
</div>
)}
</div>
{atsFor && <AtsMatch candidate={atsFor} onClose={() => setAtsFor(null)} onProfile={(c) => { setAtsFor(null); openProfile(c) }} />}
{atsFor && (
<AtsMatch
candidate={atsFor}
jobTitle={jobTitleOf(atsFor)}
onClose={() => setAtsFor(null)}
onProfile={(c) => { setAtsFor(null); openProfile(c) }}
/>
)}
{profileFor && (
<CandidateProfile
candidate={candidates.find((c) => c.id === profileFor.id) ?? profileFor}
jobTitle={jobTitleOf(profileFor)}
onClose={() => setProfileFor(null)}
onAdvance={advance}
onToggleFav={toggleFav}
onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }}
/>
)}
{bulkAssigning && (
<BulkAssign
count={selected.size}
recruiters={recruiters}
onClose={() => setBulkAssigning(false)}
onSave={(name) => {
updateCandidates((cs) => cs.map((c) => (selected.has(c.id) ? { ...c, recruiter: name } : c)))
setBulkAssigning(false)
setSelected(new Set())
toast('Recruiter assigned to selected candidates', 'success')
}}
onAtsMatch={(c) => { setProfileFor(null); openAts(c) }}
/>
)}
{adding && (
<AddCandidate
jobs={jobs}
count={candidates.length}
onClose={() => setAdding(false)}
onSave={(c) => {
updateCandidates((cs) => [c, ...cs])
onSave={() => {
setAdding(false)
toast('Candidate added to pipeline', 'success')
}}
@ -482,37 +423,29 @@ export default function Candidates() {
)
}
function Facet({ label, value, onChange, any, options }) {
function Facet({ label, value, onChange, any, options, labels }) {
return (
<div className="form-field">
<label>{label}</label>
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">{any}</option>
{options.map((o) => <option key={o}>{o}</option>)}
{options.map((o) => <option key={o} value={o}>{labels?.[o] ?? o}</option>)}
</select>
</div>
)
}
/** Exported so TalentPool's profile modal can open the same ATS breakdown. */
export function AtsMatch({ candidate: c, onClose, onProfile }) {
const sub = c.subScores
const recCls = c.recommendation === 'Strong Match' ? 'recc-strong'
: c.recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'
export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
const recommendation = recommendationOf(c)
const recCls = recommendation === 'Strong Match' ? 'recc-strong'
: recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'
const ringColor = c.aiScore >= 82 ? 'var(--success)' : c.aiScore >= 65 ? 'var(--warning)' : 'var(--danger)'
const Row = ({ label, val }) => (
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
<span style={{ width: 110, fontSize: 13 }}>{label}</span>
<div style={{ flex: 1 }}><ProgressBar pct={val} /></div>
<b style={{ width: 42, textAlign: 'right' }}>{val}%</b>
</div>
)
return (
<Modal
title="ATS Match Analysis"
subtitle={`${c.id} · ${c.jobTitle}`}
subtitle={jobTitle}
size="modal-lg"
onClose={onClose}
footer={
@ -524,11 +457,11 @@ export function AtsMatch({ candidate: c, onClose, onProfile }) {
>
<div className={`recc-banner ${recCls}`}>
<span className="recc-icn">
<Icon name={c.recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
<Icon name={recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
</span>
<div style={{ flex: 1 }}>
<div className="fw-600" style={{ fontSize: 15 }}>{c.recommendation}</div>
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name} for {c.jobTitle}</div>
<div className="fw-600" style={{ fontSize: 15 }}>{recommendation}</div>
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name}{jobTitle ? ` for ${jobTitle}` : ''}</div>
</div>
</div>
@ -542,12 +475,8 @@ export function AtsMatch({ candidate: c, onClose, onProfile }) {
</div>
</div>
<div>
<Row label="Skills" val={sub.skills} />
<Row label="Experience" val={sub.experience} />
<Row label="Education" val={sub.education} />
<Row label="Keywords" val={sub.keywords} />
<Row label="Location" val={sub.location} />
<Row label="Salary" val={sub.salary} />
<div className="form-section-title" style={{ marginTop: 0 }}>Assessment</div>
<p className="text-muted" style={{ fontSize: 13 }}>{c.critique ?? '—'}</p>
</div>
</div>
@ -575,37 +504,14 @@ export function AtsMatch({ candidate: c, onClose, onProfile }) {
<div className="divider" />
<p className="text-muted text-sm">
<Icon name="sparkles" /> Score computed from JD keywords, resume parsing, experience,
education, location and salary alignment. Connect an AI model to refine with semantic matching.
<Icon name="sparkles" /> Scored by the ATS engine against the job post's requirements.
Matched skills are verified to appear in the resume text; the one-line assessment is
model-generated and evidence-based.
</p>
</Modal>
)
}
function BulkAssign({ count, recruiters, onClose, onSave }) {
const [name, setName] = useState(recruiters[0]?.name ?? '')
return (
<Modal
title="Bulk Assign Recruiter"
subtitle={`${count} candidates`}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
</>
}
>
<div className="form-field">
<label>Assign to</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
</select>
</div>
</Modal>
)
}
/**
* Add Candidate the only writer on this screen that reaches the server.
*
@ -618,14 +524,11 @@ function BulkAssign({ count, recruiters, onClose, onSave }) {
* job_post_id is a job_posts FK and a seed id would be coerced to NULL without
* an error the link would look saved and simply not exist.
*
* The row handed to onSave is still seed-shaped. Nothing on this screen reads
* /candidate/fetch manual rows do not pass through `inbox`, so they surface
* neither here nor in Talent Pool and dropping the candidate the recruiter
* just created out of the table would read as a failed save. The fabricated
* scoring fields are the pre-existing seed shape, unchanged; only the identity
* fields now carry what was actually posted.
* Manual rows do not pass through `inbox`, so /candidate/fetch may not surface
* them immediately; the save still invalidates the candidates query so the
* live-backed screens refetch and pick the row up once an application links it.
*/
function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) {
function AddCandidate({ onClose, onSave, onInvalid }) {
const { toast } = useToast()
const qc = useQueryClient()
const fileInput = useRef(null)
@ -655,46 +558,14 @@ function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) {
const create = useMutation({
mutationFn: (vars) => candidatesApi.createManual(vars),
onError: (err) => toast(friendlyAuthError(err, 'Could not add the candidate.'), 'error'),
onSuccess: (res) => {
onSuccess: () => {
// The new user_id lands in /candidate/fetch's join the moment an
// application exists for them, so let the live-backed screens refetch.
qc.invalidateQueries({ queryKey: qk.candidates.all() })
onSave(buildRow(res?.data))
onSave()
},
})
function buildRow(saved) {
const v = form.values
const post = posts.find((p) => String(p.id) === jobPostId)
const title = post?.title || v.job || jobs[0]?.title || 'Unassigned'
// Department, location, recruiter and skills are presentation-only columns
// the endpoint does not return borrow them from the seed job of the same
// title so the row renders like every other one.
const job = jobs.find((j) => j.title === title) || jobs[0] || {}
const skills = job.skills ?? []
const score = int(55, 95)
return {
id: `CAN-${5001 + count}`,
userId: saved?.user_id ?? null,
manualUploadId: saved?.id ?? null,
name: v.name, initials: initialsOf(v.name), color: avatarColor(v.name),
email: v.email, phone: v.phone || '+1 (555) 000-0000',
jobId: job.id, jobTitle: title, department: job.department,
experience: Number(v.experience) || 1, currentCompany: v.company || '—',
currentTitle: title, location: job.location,
stage: v.stage, status: v.stage, aiScore: score, source: v.source,
referredBy: referralValue(v.referral) || null,
recruiter: job.recruiter, recruiterId: job.recruiterId,
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: skills.slice(0, 4), rating: '4.0', salary: 120000,
matchedSkills: skills.slice(0, 3), missingSkills: skills.slice(3),
recommendation: score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match',
subScores: { skills: score, experience: 80, education: 80, keywords: score, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
}
}
function pickFile(next) {
if (!next) return
setCv(next)

View File

@ -1,169 +1,135 @@
/* ============================================================
CV Import the UX shape is right; the mechanics are still simulated.
CV Import real upload score persist flow.
The prototype's dropzone read only `e.dataTransfer.files.length` and threw
the files away, then invented a queue with setInterval-driven progress. That
is preserved deliberately: there is no upload endpoint, no object storage and
no parser behind this yet, so pretending otherwise would be worse than the
honest "processed locally in this demo" label the screen already carries.
Files go to POST /candidate/score as one multipart batch: the backend
extracts each PDF, scores it against the selected job with the ATS engine,
and persists a row per file. Unreadable/oversized/non-PDF files come back
as status "failed" rows instead of failing the batch, and re-uploading the
same bytes updates the existing record (content-hash dedupe) so there is
no separate "import" step and no duplicate modal anymore.
============================================================ */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Badge, Icon, ScoreChip } from '../ui/primitives'
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import {
avatarColor, companies, initials as initialsOf, int, locations, pick, TODAY,
} from '../data/seed'
const FIRST = ['Olivia', 'Liam', 'Emma', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Priya', 'Diego', 'Yuki', 'Omar']
const LAST = ['Chen', 'Patel', 'Kim', 'Garcia', 'Silva', 'Ahmed', 'Novak', 'Reyes', 'Khan', 'Costa']
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
const STEPS = [
{ i: 'file', t: 'Resume parsing', d: 'Extract name, contact, experience, skills & education' },
{ i: 'target', t: 'ATS scoring', d: 'Generate a match score against the requisition' },
{ i: 'briefcase', t: 'Job matching', d: 'Suggest the best-matching open roles' },
{ i: 'users', t: 'Duplicate detection', d: 'Flag candidates already in the system' },
{ i: 'user-plus', t: 'Profile creation', d: 'Create a candidate profile in Applied stage' },
{ i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' },
{ i: 'target', t: 'ATS scoring', d: 'LLM match score with matched & missing skills vs the selected job' },
{ i: 'users', t: 'Duplicate detection', d: 'Re-uploading the same file updates its existing record' },
{ i: 'user-plus', t: 'Saved to pool', d: 'Results persist — see Candidates and Talent Pool' },
]
async function fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title }))
}
function fmtSize(bytes) {
if (!Number.isFinite(bytes)) return ''
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
let rowSeq = 0
export default function CvImport() {
const { toast } = useToast()
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const updateCandidates = useSeedMutation('candidates')
const qc = useQueryClient()
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const jobs = jobsQuery.data ?? []
const [jobId, setJobId] = useState('')
const [queue, setQueue] = useState([])
const [dragging, setDragging] = useState(false)
const [duplicateFor, setDuplicateFor] = useState(null)
const timers = useRef(new Set())
const fileInput = useRef(null)
useEffect(() => {
const set = timers.current
return () => {
set.forEach((t) => { clearInterval(t); clearTimeout(t) })
set.clear()
}
}, [])
const advance = useCallback((id) => {
const tick = setInterval(() => {
const scoring = useMutation({
mutationFn: ({ job, files }) => candidatesApi.scoreUploads(job, files),
onSuccess: (res, vars) => {
const rows = Array.isArray(res?.data) ? res.data : []
setQueue((q) =>
q.map((item) => {
if (item.id !== id || item.status !== 'Uploading') return item
const progress = Math.min(100, item.progress + int(12, 30))
if (progress >= 100) {
clearInterval(tick)
timers.current.delete(tick)
const done = setTimeout(() => {
setQueue((q2) =>
q2.map((x) => (x.id === id ? { ...x, status: 'Ready', atsScore: int(52, 96) } : x)),
)
timers.current.delete(done)
}, 700 + int(0, 500))
timers.current.add(done)
return { ...item, progress: 100, status: 'Parsing' }
if (!vars.rowIds.includes(item.id)) return item
const match = rows.find((r) => r.filename === item.file)
if (!match) return { ...item, status: 'Failed', error: 'NO_RESULT' }
if (match.status !== 'completed') {
return { ...item, status: 'Failed', error: match.error_code || 'FAILED' }
}
return {
...item,
status: 'Ready',
name: match.candidate_name || item.file,
atsScore: match.match_score,
critique: match.summary_critique,
}
return { ...item, progress }
}),
)
}, 220)
timers.current.add(tick)
}, [])
const simulate = useCallback(
(count, isZip) => {
const n = isZip ? 8 : count
const open = jobs.filter((j) => j.status === 'Open')
const items = []
for (let k = 0; k < n; k++) {
const name = `${pick(FIRST)} ${pick(LAST)}`
items.push({
id: `UP-${Math.random().toString(36).slice(2, 8)}`,
name,
file: `${name.split(' ')[0]}_Resume.${pick(['pdf', 'docx', 'doc'])}`,
size: `${int(120, 620)} KB`,
progress: 0,
status: 'Uploading',
atsScore: null,
job: pick(open.length ? open : jobs),
duplicate: Math.random() < 0.18,
imported: false,
})
}
setQueue((q) => [...q, ...items])
items.forEach((i) => advance(i.id))
toast(isZip ? 'ZIP extracted — 8 resumes queued' : `${n} file(s) uploaded`, 'info')
qc.invalidateQueries({ queryKey: qk.candidates.all() })
const ok = rows.filter((r) => r.status === 'completed').length
const failed = rows.length - ok
toast(
failed
? `${ok} scored, ${failed} failed — results saved to the candidate pool`
: `${ok} resume${ok === 1 ? '' : 's'} scored and saved`,
failed ? 'warning' : 'success',
)
},
[jobs, advance, toast],
)
const doImport = useCallback(
(id) => {
const item = queue.find((x) => x.id === id)
if (!item || item.imported) return
const job = item.job
updateCandidates((cs) => [
{
id: `CAN-${5001 + cs.length}`,
name: item.name,
initials: initialsOf(item.name),
color: avatarColor(item.name),
email: `${item.name.toLowerCase().replace(/ /g, '.')}@email.com`,
phone: '+1 (555) 000-0000',
jobId: job.id, jobTitle: job.title, department: job.department,
experience: int(2, 12), currentCompany: pick(companies), currentTitle: job.title,
location: pick(locations), stage: 'Applied', status: 'Applied',
aiScore: item.atsScore, source: 'Manual CV Upload',
recruiter: job.recruiter, recruiterId: '',
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: job.skills.slice(0, 4), rating: '4.0', salary: int(90, 180) * 1000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
recommendation: item.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
// Kept verbatim from the prototype, including the literal constants
// this breakdown is fabricated and is flagged as the most misleading
// artefact in the repo (01-repository-assessment.md §2.2).
subScores: { skills: item.atsScore, experience: 80, education: 80, keywords: item.atsScore, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
},
...cs,
])
setQueue((q) => q.map((x) => (x.id === id ? { ...x, imported: true } : x)))
toast(`${item.name} imported → ${job.title}`, 'success')
onError: (err, vars) => {
setQueue((q) =>
q.map((item) =>
vars.rowIds.includes(item.id) ? { ...item, status: 'Failed', error: 'REQUEST_FAILED' } : item,
),
)
toast(friendlyAuthError(err, 'Scoring failed'), 'error')
},
[queue, updateCandidates, toast],
)
})
function importOne(item) {
if (item.duplicate) setDuplicateFor(item)
else doImport(item.id)
}
function importAll() {
const ready = queue.filter((i) => i.status === 'Ready' && !i.imported && !i.duplicate)
if (!ready.length) {
toast('No files ready to import', 'warning')
function handleFiles(fileList) {
const all = Array.from(fileList || [])
if (!all.length) return
if (!jobId) {
toast('Select a job to score against first', 'warning')
return
}
ready.forEach((i) => doImport(i.id))
toast(`${ready.length} candidates imported`, 'success')
const files = all.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
const skipped = all.length - files.length
if (skipped) toast(`Only PDF resumes are supported — ${skipped} file(s) skipped`, 'warning')
if (!files.length) return
const items = files.map((f) => ({
id: `UP-${++rowSeq}-${Date.now()}`,
name: f.name,
file: f.name,
size: fmtSize(f.size),
status: 'Scoring',
atsScore: null,
critique: null,
error: null,
}))
setQueue((q) => [...q, ...items])
scoring.mutate({ job: jobId, files, rowIds: items.map((i) => i.id) })
}
const importedCount = queue.filter((i) => i.imported).length
const scored = queue.filter((i) => i.status === 'Ready').length
const failed = queue.filter((i) => i.status === 'Failed').length
const selectedJob = jobs.find((j) => j.id === jobId)
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">CV Import</h1>
<p className="page-sub">Upload resumes we parse, score, match, and dedupe automatically</p>
<p className="page-sub">Upload resume PDFs parsed, scored against a job, and saved automatically</p>
</div>
<div className="page-head-actions">
<span className="integration-status pending"><span className="pulse" />AI Resume Parser · Ready</span>
<span className="integration-status pending"><span className="pulse" />AI Resume Scoring · Live</span>
</div>
</div>
@ -171,45 +137,59 @@ export default function CvImport() {
<div>
<div className="card mb-18">
<div className="card-body">
<div className="flex items-center gap-8" style={{ marginBottom: 16 }}>
<span className="fw-600 text-sm" style={{ flexShrink: 0 }}>Score against</span>
<select
className="select"
style={{ flex: 1 }}
value={jobId}
onChange={(e) => setJobId(e.target.value)}
>
<option value="">Select a job post</option>
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
</select>
</div>
{jobsQuery.isError && (
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
{friendlyAuthError(jobsQuery.error, 'Could not load job posts')}
</p>
)}
<div
className={`dropzone${dragging ? ' drag' : ''}`}
onClick={() => simulate(int(2, 4))}
onClick={() => fileInput.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
simulate(e.dataTransfer.files.length || int(2, 4))
handleFiles(e.dataTransfer.files)
}}
>
<input
ref={fileInput}
type="file"
accept=".pdf,application/pdf"
multiple
hidden
onChange={(e) => { handleFiles(e.target.files); e.target.value = '' }}
/>
<div className="dz-icn"><Icon name="upload" /></div>
<h3>Drag &amp; drop resumes here</h3>
<p className="text-muted" style={{ marginBottom: 16 }}>
or click to browse PDF, DOC, DOCX and ZIP supported · up to 20 files
or click to browse PDF only · up to 50 files per batch
</p>
<button
className="btn btn-primary"
onClick={(e) => { e.stopPropagation(); simulate(int(2, 4)) }}
onClick={(e) => { e.stopPropagation(); fileInput.current?.click() }}
>
<Icon name="upload" /> Browse Files
</button>
<div className="flex items-center gap-8" style={{ justifyContent: 'center', marginTop: 16 }}>
{['PDF', 'DOC', 'DOCX', 'ZIP'].map((t) => (
<span className="badge b-gray badge-plain" key={t}>{t}</span>
))}
<span className="badge b-gray badge-plain">PDF</span>
<span className="text-muted text-sm">DOC / DOCX support coming later</span>
</div>
</div>
<div className="flex items-center gap-8" style={{ marginTop: 16, flexWrap: 'wrap' }}>
<button className="btn btn-secondary btn-sm" onClick={() => simulate(3)}>
<Icon name="sparkles" /> Simulate 3 files
</button>
<button className="btn btn-secondary btn-sm" onClick={() => simulate(1, true)}>
<Icon name="layers" /> Simulate ZIP (8 CVs)
</button>
<span className="text-muted text-sm" style={{ marginLeft: 'auto' }}>
Files are processed locally in this demo
</span>
</div>
</div>
</div>
@ -219,12 +199,11 @@ export default function CvImport() {
<div>
<h3>Processing Queue</h3>
<span className="ch-sub">
{queue.length} file{queue.length === 1 ? '' : 's'} · {importedCount} imported
{queue.length} file{queue.length === 1 ? '' : 's'} · {scored} scored
{failed ? ` · ${failed} failed` : ''}
{selectedJob ? ` · vs ${selectedJob.title}` : ''}
</span>
</div>
<button className="btn btn-primary btn-sm" onClick={importAll}>
<Icon name="check" /> Import All
</button>
</div>
<div className="card-body">
{queue.map((i) => (
@ -233,46 +212,39 @@ export default function CvImport() {
<div style={{ flex: 1, minWidth: 0 }}>
<div className="flex items-center gap-8">
<span className="fw-600 text-sm">{i.name}</span>
{i.duplicate && (
<span className="badge b-red badge-plain" style={{ padding: '1px 7px', fontSize: 10 }}>
DUPLICATE
</span>
)}
</div>
<div className="cell-sub">{i.file} · {i.size}</div>
{i.status === 'Uploading' || i.status === 'Parsing' ? (
{i.status === 'Scoring' && (
<div className="upload-progress" style={{ marginTop: 6 }}>
<div className="upload-progress-fill" style={{ width: `${i.progress}%` }} />
</div>
) : (
<div className="cell-sub" style={{ marginTop: 4 }}>
Best match: <b>{i.job?.title}</b>
<div className="upload-progress-fill" style={{ width: '66%' }} />
</div>
)}
{i.status === 'Ready' && i.critique && (
<div className="cell-sub" style={{ marginTop: 4 }}>{i.critique}</div>
)}
{i.status === 'Failed' && (
<div className="cell-sub" style={{ marginTop: 4 }}>Could not be scored</div>
)}
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
{i.status === 'Ready' ? (
<ScoreChip score={i.atsScore} />
) : (
<Badge className={i.status === 'Parsing' ? 'b-amber' : 'b-blue'}>
{i.status}{i.status === 'Uploading' ? ` ${i.progress}%` : ''}
</Badge>
)}
{i.status === 'Ready' && <ScoreChip score={i.atsScore} />}
{i.status === 'Scoring' && <Badge className="b-blue">Scoring</Badge>}
{i.status === 'Failed' && <Badge className="b-red">{i.error}</Badge>}
</div>
<div style={{ flexShrink: 0 }}>
{i.imported ? (
<Badge className="b-green">Imported</Badge>
) : i.status === 'Ready' ? (
<button className="btn btn-primary btn-sm" onClick={() => importOne(i)}>Import</button>
) : (
<button className="act-btn" disabled><Icon name="clock" /></button>
)}
{i.status === 'Ready' && <Badge className="b-green">Saved</Badge>}
</div>
</div>
))}
</div>
</div>
)}
{queue.length === 0 && jobsQuery.isSuccess && jobs.length === 0 && (
<EmptyState icon="briefcase" title="No job posts yet">
Create a job post first resumes are always scored against a job.
</EmptyState>
)}
</div>
<div className="card" style={{ alignSelf: 'start' }}>
@ -290,44 +262,6 @@ export default function CvImport() {
</div>
</div>
</div>
{duplicateFor && (
<Modal
title="Duplicate Detected"
subtitle={duplicateFor.name}
onClose={() => setDuplicateFor(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setDuplicateFor(null)}>Cancel</button>
<button
className="btn btn-secondary"
onClick={() => { setDuplicateFor(null); toast('Merged into existing profile', 'success') }}
>
Merge
</button>
<button
className="btn btn-primary"
onClick={() => { const id = duplicateFor.id; setDuplicateFor(null); doImport(id) }}
>
Import Anyway
</button>
</>
}
>
<div className="flex gap-16 items-center">
<span className="kpi-icn i-amber" style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}>
<Icon name="users" />
</span>
<div>
<p className="fw-600" style={{ fontSize: 15 }}>A similar candidate already exists</p>
<p className="text-muted" style={{ marginTop: 4 }}>
{duplicateFor.name} matches an existing profile (95% similarity on name + email).
Importing will create a duplicate.
</p>
</div>
</div>
</Modal>
)}
</div>
)
}

View File

@ -4,47 +4,236 @@ import { useQuery } from '@tanstack/react-query'
import Chart, { ChartLegend } from '../ui/Chart'
import Charts from '../lib/charts'
import { Avatar, Icon, KpiCard, ScoreChip } from '../ui/primitives'
import { seedQuery } from '../data/seedQueries'
import { analytics, fmtShort, kpis, money, relTime } from '../data/seed'
import { Avatar, EmptyState, Icon, KpiCard, ScoreChip } from '../ui/primitives'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import { fmtShort, money, relTime, initials as initialsOf, avatarColor } from '../data/seed'
import * as analyticsApi from '../api/analytics'
import * as interviewsApi from '../api/interviews'
import * as activityApi from '../api/activity'
import * as candidatesApi from '../api/candidates'
const POLL_MS = 60_000
function asObject(data) {
return data && typeof data === 'object' && !Array.isArray(data) ? data : null
}
function asList(data) {
return Array.isArray(data) ? data : []
}
function pctDelta(cur, prior) {
if (cur == null || prior == null || prior === 0) return null
const d = ((Number(cur) - Number(prior)) / Math.abs(Number(prior))) * 100
if (!Number.isFinite(d)) return null
return `${d >= 0 ? '+' : ''}${Math.round(d)}%`
}
function dayDelta(cur, prior) {
if (cur == null || prior == null) return null
const d = Math.round(Number(prior) - Number(cur))
if (!Number.isFinite(d) || d === 0) return null
return `${d > 0 ? '-' : '+'}${Math.abs(d)} days`
}
function greetingFor(now = new Date()) {
const h = now.getHours()
if (h < 12) return 'Good morning'
if (h < 17) return 'Good afternoon'
return 'Good evening'
}
function formatDashDate(d = new Date()) {
return d.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})
}
function initialsFrom(name) {
if (!name) return '?'
try {
return initialsOf(name)
} catch {
return String(name).slice(0, 2).toUpperCase()
}
}
function mapInterviewRow(iv) {
const whenRaw = iv.interview_date || iv.interview_time
const when = whenRaw ? new Date(whenRaw) : null
const name = iv.candidate_name || 'Candidate'
return {
id: iv.id,
candidate: name,
candInitials: initialsFrom(name),
color: avatarColor(name),
type: iv.interview_type || 'Interview',
jobTitle: iv.job_title || '',
when,
status: iv.interview_status || '',
}
}
function mapCandidateRow(c) {
const name = c.name || 'Candidate'
const appliedRaw = c.created_at || c.applied
return {
id: c.user_id || c.inbox_id || name,
userId: c.user_id,
name,
initials: initialsFrom(name),
color: avatarColor(name),
jobTitle: c.current_employment || c.experience || '—',
aiScore: c.ats_score ?? c.ai_score ?? null,
applied: appliedRaw ? new Date(appliedRaw) : new Date(0),
}
}
function mapActivityRow(a) {
const desc = a.description || a.activity_type || 'Activity'
const actor = a.actor_name
const parts = actor
? [{ b: actor }, ` ${desc}`]
: [desc]
const whenRaw = a.activity_date || a.activity_time
const when = whenRaw ? new Date(whenRaw) : null
const mins = when && !Number.isNaN(when.getTime())
? Math.max(0, Math.round((Date.now() - when.getTime()) / 60000))
: 0
const type = (a.activity_type || '').toLowerCase()
let icon = 'file'
let color = 'i-indigo'
if (type.includes('interview')) { icon = 'calendar'; color = 'i-blue' }
else if (type.includes('offer')) { icon = 'check'; color = 'i-teal' }
else if (type.includes('hire') || type.includes('stage')) { icon = 'user-plus'; color = 'i-green' }
return {
id: a.id,
icon,
color,
parts,
time: mins,
candidateId: a.inbox_id,
}
}
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {
if (query.isPending) {
return (
<EmptyState icon="clock" title={`Loading ${title}`}>
Fetching from the server
</EmptyState>
)
}
if (query.isError) {
return (
<EmptyState icon="alert" title={`Couldnt load ${title}`}>
{friendlyAuthError(query.error, `The server did not return ${title}.`)}
{' '}This widget needs the <code>{permission}</code> permission.
</EmptyState>
)
}
const rows = asList(query.data)
if (rows.length === 0) {
return (
<EmptyState icon="inbox" title={emptyTitle || `No ${title} yet`}>
{emptyHint || 'Nothing to show for this window.'}
</EmptyState>
)
}
return children(rows)
}
export default function Dashboard() {
const navigate = useNavigate()
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const { data: activity = [] } = useQuery(seedQuery('activity'))
const { user } = useAuth()
const firstName = (user?.name || 'there').split(' ')[0]
const todayLabel = formatDashDate()
const k = kpis
const kpisQuery = useQuery({
queryKey: qk.analytics.kpis(),
queryFn: async () => asObject((await analyticsApi.kpis()).data),
refetchInterval: POLL_MS,
})
const trendQuery = useQuery({
queryKey: qk.analytics.trend({ months: 7 }),
queryFn: async () => asObject((await analyticsApi.hiringTrend({ months: 7 })).data) || {
labels: [],
applications: [],
hires: [],
},
refetchInterval: POLL_MS,
})
const funnelQuery = useQuery({
queryKey: qk.analytics.funnel(),
queryFn: async () => asList((await analyticsApi.funnel()).data),
refetchInterval: POLL_MS,
})
const sourcesQuery = useQuery({
queryKey: qk.analytics.sources(),
queryFn: async () => asList((await analyticsApi.sourcePerformance()).data),
refetchInterval: POLL_MS,
})
const recruitersQuery = useQuery({
queryKey: qk.analytics.recruiters({ top: 5 }),
queryFn: async () => asList((await analyticsApi.recruiterPerformance({ top: 5 })).data),
refetchInterval: POLL_MS,
})
// Chart payloads must be referentially stable, or <Chart/> re-runs its effect
// and re-animates on every parent render.
const trendData = useMemo(
() => ({
labels: analytics.hiringTrend.labels,
const interviewsQuery = useQuery({
queryKey: qk.interviews.range({ status: 'Scheduled', top: 5 }),
queryFn: async () => asList((await interviewsApi.listRange({ status: 'Scheduled', top: 5 })).data)
.map(mapInterviewRow),
})
const candidatesQuery = useQuery({
queryKey: qk.candidates.list({ limit: 5 }),
queryFn: async () => {
const rows = candidatesApi.toRows(await candidatesApi.list({ limit: 5, offset: 0 }))
return asList(rows).map(mapCandidateRow).sort((a, b) => b.applied - a.applied).slice(0, 5)
},
})
const activityQuery = useQuery({
queryKey: qk.activity.feed({ top: 8 }),
queryFn: async () => asList((await activityApi.feed({ top: 8 })).data).map(mapActivityRow),
})
const k = kpisQuery.data
const trendData = useMemo(() => {
const t = trendQuery.data || { labels: [], applications: [], hires: [] }
return {
labels: t.labels || [],
area: true,
datasets: [
{ label: 'Applications', data: analytics.hiringTrend.applications, color: Charts.PALETTE[4] },
{ label: 'Hires', data: analytics.hiringTrend.hires, color: Charts.PALETTE[0] },
{ label: 'Applications', data: t.applications || [], color: Charts.PALETTE[4] },
{ label: 'Hires', data: t.hires || [], color: Charts.PALETTE[0] },
],
}),
[],
)
const pipelineData = useMemo(
() => ({
labels: analytics.pipeline.map((p) => p.stage),
data: analytics.pipeline.map((p) => p.count),
}
}, [trendQuery.data])
const pipelineData = useMemo(() => {
const rows = asList(funnelQuery.data)
return {
labels: rows.map((p) => p.stage),
data: rows.map((p) => p.count),
colors: Charts.PALETTE,
}),
[],
)
const sourceData = useMemo(
() => ({
labels: analytics.sources.map((s) => s.source),
data: analytics.sources.map((s) => s.count),
}),
[],
)
}
}, [funnelQuery.data])
const sourceData = useMemo(() => {
const rows = asList(sourcesQuery.data)
return {
labels: rows.map((s) => s.source),
data: rows.map((s) => s.count),
}
}, [sourcesQuery.data])
const legend = useMemo(
() => [
{ label: 'Applications', color: Charts.PALETTE[4] },
@ -53,30 +242,101 @@ export default function Dashboard() {
[],
)
const upcoming = interviews.filter((iv) => iv.status === 'Scheduled').slice(0, 5)
const recentApps = [...candidates].sort((a, b) => b.applied - a.applied).slice(0, 5)
const topRecruiters = [...recruiters].sort((a, b) => b.hires - a.hires).slice(0, 5)
const pending = kpisQuery.isPending
const dash = (v) => (pending || v == null || v === '' ? '—' : v)
const row1 = [
{ label: 'Open Jobs', value: k.openJobs, icon: 'briefcase', tone: 'i-indigo', trend: '+8%', dir: 'up', foot: 'vs last month' },
{ label: 'Total Candidates', value: k.totalCandidates, icon: 'users', tone: 'i-blue', trend: '+12%', dir: 'up', foot: 'active in pipeline' },
{ label: 'Interviews Today', value: k.interviewsToday, icon: 'calendar', tone: 'i-purple', trend: '3 upcoming', dir: 'flat', foot: 'next at 2:00 PM' },
{ label: 'Offers Accepted', value: k.offersAccepted, icon: 'check-circle', tone: 'i-green', trend: '+5%', dir: 'up', foot: `of ${k.offersSent} sent` },
{
label: 'Open Jobs',
value: dash(k?.open_jobs),
icon: 'briefcase',
tone: 'i-indigo',
trend: pctDelta(k?.open_jobs, k?.open_jobs_prior) || '—',
dir: Number(k?.open_jobs) >= Number(k?.open_jobs_prior) ? 'up' : 'down',
foot: 'vs prior window',
},
{
label: 'Total Candidates',
value: dash(k?.total_candidates),
icon: 'users',
tone: 'i-blue',
trend: pctDelta(k?.total_candidates, k?.total_candidates_prior) || '—',
dir: Number(k?.total_candidates) >= Number(k?.total_candidates_prior) ? 'up' : 'down',
foot: 'active in pipeline',
},
{
label: 'Interviews Today',
value: dash(k?.interviews_today),
icon: 'calendar',
tone: 'i-purple',
trend: k?.interviews_upcoming != null ? `${k.interviews_upcoming} upcoming` : '—',
dir: 'flat',
foot: k?.next_interview_at
? `next at ${new Date(k.next_interview_at).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`
: 'no upcoming',
},
{
label: 'Offers Accepted',
value: dash(k?.offers_accepted),
icon: 'check-circle',
tone: 'i-green',
trend: pctDelta(k?.offers_accepted, k?.offers_accepted_prior) || '—',
dir: Number(k?.offers_accepted) >= Number(k?.offers_accepted_prior) ? 'up' : 'down',
foot: k?.offers_sent != null ? `of ${k.offers_sent} sent` : '—',
},
]
const row2 = [
{ label: 'Time to Hire', value: `${k.timeToHire} days`, icon: 'clock', tone: 'i-teal', trend: '-3 days', dir: 'up', foot: 'faster than target' },
{ label: 'Time to Fill', value: `${k.timeToFill} days`, icon: 'target', tone: 'i-amber', trend: '-2 days', dir: 'up', foot: '41 day average' },
{ label: 'Cost per Hire', value: money(k.costPerHire), icon: 'dollar', tone: 'i-red', trend: '+4%', dir: 'down', foot: 'above budget' },
{ label: 'Closed Jobs', value: k.closedJobs + k.hires, icon: 'award', tone: 'i-purple', trend: '+15%', dir: 'up', foot: 'this quarter' },
{
label: 'Time to Hire',
value: k?.time_to_hire != null && !pending ? `${Math.round(k.time_to_hire)} days` : '—',
icon: 'clock',
tone: 'i-teal',
trend: dayDelta(k?.time_to_hire, k?.time_to_hire_prior) || '—',
dir: Number(k?.time_to_hire) <= Number(k?.time_to_hire_prior) ? 'up' : 'down',
foot: k?.time_to_hire == null ? 'no hires in window' : 'vs prior window',
},
{
label: 'Time to Fill',
value: k?.time_to_fill != null && !pending ? `${Math.round(k.time_to_fill)} days` : '—',
icon: 'target',
tone: 'i-amber',
trend: dayDelta(k?.time_to_fill, k?.time_to_fill_prior) || '—',
dir: Number(k?.time_to_fill) <= Number(k?.time_to_fill_prior) ? 'up' : 'down',
foot: k?.time_to_fill == null ? 'no closes in window' : 'vs prior window',
},
{
label: 'Cost per Hire',
value: k?.cost_per_hire != null && !pending ? money(Math.round(k.cost_per_hire)) : '—',
icon: 'dollar',
tone: 'i-red',
trend: pctDelta(k?.cost_per_hire, k?.cost_per_hire_prior) || '—',
dir: Number(k?.cost_per_hire) <= Number(k?.cost_per_hire_prior) ? 'up' : 'down',
foot: k?.cost_per_hire == null ? 'no cost data yet' : 'vs prior window',
},
{
label: 'Closed Jobs',
value: dash(
k == null ? null : Number(k.closed_jobs || 0) + Number(k.hires || 0),
),
icon: 'award',
tone: 'i-purple',
trend: pctDelta(
Number(k?.closed_jobs || 0) + Number(k?.hires || 0),
Number(k?.closed_jobs_prior || 0) + Number(k?.hires_prior || 0),
) || '—',
dir: 'up',
foot: 'this window',
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Good morning, Asfand 👋</h1>
<h1 className="page-title">{greetingFor()}, {firstName} 👋</h1>
<p className="page-sub">
Heres whats happening with your hiring today Thursday, July 9, 2026
Heres whats happening with your hiring today {todayLabel}
</p>
</div>
<div className="page-head-actions">
@ -101,7 +361,9 @@ export default function Dashboard() {
<div className="card-head">
<div>
<h3>Hiring Trend</h3>
<span className="ch-sub">Hires vs applications over the last 7 months</span>
<span className="ch-sub">
{trendQuery.isPending ? 'Loading…' : 'Hires vs applications over the last 7 months'}
</span>
</div>
<div className="pill-tabs">
<span className="pill-tab active">7M</span>
@ -109,23 +371,43 @@ export default function Dashboard() {
</div>
</div>
<div className="card-body">
<div className="chart-wrap">
<Chart type="line" data={trendData} height={280} />
</div>
<ChartLegend items={legend} />
{trendQuery.isError ? (
<EmptyState icon="alert" title="Couldnt load hiring trend">
{friendlyAuthError(trendQuery.error, 'The server did not return the trend.')}
{' '}This widget needs the <code>analytics.view</code> permission.
</EmptyState>
) : (
<>
<div className="chart-wrap">
<Chart type="line" data={trendData} height={280} />
</div>
<ChartLegend items={legend} />
</>
)}
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Candidate Pipeline</h3>
<span className="ch-sub">Active by stage</span>
<span className="ch-sub">{funnelQuery.isPending ? 'Loading…' : 'Active by stage'}</span>
</div>
</div>
<div className="card-body">
<div className="chart-wrap">
<Chart type="horizontalBar" data={pipelineData} height={280} />
</div>
{funnelQuery.isError ? (
<EmptyState icon="alert" title="Couldnt load pipeline">
{friendlyAuthError(funnelQuery.error, 'The server did not return the funnel.')}
{' '}This widget needs the <code>analytics.view</code> permission.
</EmptyState>
) : asList(funnelQuery.data).length === 0 && funnelQuery.isSuccess ? (
<EmptyState icon="inbox" title="No pipeline data yet">
Stage counts appear once applications are in the system.
</EmptyState>
) : (
<div className="chart-wrap">
<Chart type="horizontalBar" data={pipelineData} height={280} />
</div>
)}
</div>
</div>
</div>
@ -141,10 +423,14 @@ export default function Dashboard() {
</div>
<div className="card-body">
<div className="list-tight">
{upcoming.length === 0 ? (
<div className="empty-state" style={{ padding: 30 }}>No upcoming interviews</div>
) : (
upcoming.map((iv) => (
<ListGate
query={interviewsQuery}
title="interviews"
permission="candidates.view"
emptyTitle="No upcoming interviews"
emptyHint="Scheduled interviews will show up here."
>
{(upcoming) => upcoming.map((iv) => (
<div
key={iv.id}
className="list-row"
@ -154,17 +440,19 @@ export default function Dashboard() {
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
<div className="lr-main">
<div className="lr-title">{iv.candidate}</div>
<div className="lr-sub">{iv.type} · {iv.jobTitle}</div>
<div className="lr-sub">{iv.type} · {iv.jobTitle || '—'}</div>
</div>
<div className="lr-right">
<div className="fw-600 text-sm">{fmtShort(iv.when)}</div>
<div className="fw-600 text-sm">{iv.when ? fmtShort(iv.when) : '—'}</div>
<div className="lr-sub">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
{iv.when
? iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
: '—'}
</div>
</div>
</div>
))
)}
))}
</ListGate>
</div>
</div>
</div>
@ -172,13 +460,26 @@ export default function Dashboard() {
<div className="card-head">
<div>
<h3>Source Analytics</h3>
<span className="ch-sub">Where candidates come from</span>
<span className="ch-sub">
{sourcesQuery.isPending ? 'Loading…' : 'Where candidates come from'}
</span>
</div>
</div>
<div className="card-body">
<div className="chart-wrap">
<Chart type="bar" data={sourceData} height={240} />
</div>
{sourcesQuery.isError ? (
<EmptyState icon="alert" title="Couldnt load sources">
{friendlyAuthError(sourcesQuery.error, 'The server did not return source analytics.')}
{' '}This widget needs the <code>analytics.view</code> permission.
</EmptyState>
) : asList(sourcesQuery.data).length === 0 && sourcesQuery.isSuccess ? (
<EmptyState icon="inbox" title="No source data yet">
Source channels appear after applications are tagged.
</EmptyState>
) : (
<div className="chart-wrap">
<Chart type="bar" data={sourceData} height={240} />
</div>
)}
</div>
</div>
</div>
@ -191,21 +492,30 @@ export default function Dashboard() {
</div>
<div className="card-body">
<div className="list-tight">
{recentApps.map((c) => (
<div
key={c.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/candidates', { state: { openCandidate: c.id } })}
>
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div className="lr-main">
<div className="lr-title">{c.name}</div>
<div className="lr-sub">{c.jobTitle}</div>
<ListGate
query={candidatesQuery}
title="applications"
permission="candidates.view"
emptyTitle="No applications yet"
>
{(recentApps) => recentApps.map((c) => (
<div
key={c.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/candidates', { state: { openCandidate: c.userId || c.id } })}
>
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div className="lr-main">
<div className="lr-title">{c.name}</div>
<div className="lr-sub">{c.jobTitle}</div>
</div>
<div className="lr-right">
{c.aiScore != null ? <ScoreChip score={c.aiScore} /> : '—'}
</div>
</div>
<div className="lr-right"><ScoreChip score={c.aiScore} /></div>
</div>
))}
))}
</ListGate>
</div>
</div>
</div>
@ -214,19 +524,32 @@ export default function Dashboard() {
<div className="card-head"><div><h3>Recruiter Performance</h3></div></div>
<div className="card-body">
<div className="list-tight">
{topRecruiters.map((r) => (
<div key={r.id} className="list-row">
<Avatar name={r.name} initials={r.initials} color={r.color} />
<div className="lr-main">
<div className="lr-title">{r.name}</div>
<div className="lr-sub">{r.openReqs} open reqs · {r.avgTimeToHire}d avg</div>
</div>
<div className="lr-right">
<div className="fw-600">{r.hires}</div>
<div className="lr-sub">hires</div>
</div>
</div>
))}
<ListGate
query={recruitersQuery}
title="recruiters"
permission="analytics.view"
emptyTitle="No recruiter stats yet"
emptyHint="Assign recruiters to jobs to populate this list."
>
{(topRecruiters) => topRecruiters.map((r) => {
const name = r.name || 'Recruiter'
return (
<div key={r.id || name} className="list-row">
<Avatar name={name} initials={initialsFrom(name)} color={avatarColor(name)} />
<div className="lr-main">
<div className="lr-title">{name}</div>
<div className="lr-sub">
{r.open_reqs ?? 0} open reqs · {r.avg_time_to_hire != null ? `${Math.round(r.avg_time_to_hire)}d` : '—'} avg
</div>
</div>
<div className="lr-right">
<div className="fw-600">{r.hires ?? 0}</div>
<div className="lr-sub">hires</div>
</div>
</div>
)
})}
</ListGate>
</div>
</div>
</div>
@ -235,19 +558,26 @@ export default function Dashboard() {
<div className="card-head"><div><h3>Recent Activity</h3></div></div>
<div className="card-body" style={{ maxHeight: 360, overflowY: 'auto' }}>
<div className="list-tight">
{activity.slice(0, 8).map((a, i) => (
<div className="list-row" key={`${a.candidateId}-${i}`}>
<span className={`kpi-icn ${a.color}`} style={{ width: 36, height: 36, borderRadius: 9 }}>
<Icon name={a.icon} />
</span>
<div className="lr-main">
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>
{a.parts.map((p, j) => (typeof p === 'string' ? p : <b key={j}>{p.b}</b>))}
<ListGate
query={activityQuery}
title="activity"
permission="candidates.view"
emptyTitle="No recent activity"
>
{(activity) => activity.map((a, i) => (
<div className="list-row" key={a.id || `${a.candidateId}-${i}`}>
<span className={`kpi-icn ${a.color}`} style={{ width: 36, height: 36, borderRadius: 9 }}>
<Icon name={a.icon} />
</span>
<div className="lr-main">
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>
{a.parts.map((p, j) => (typeof p === 'string' ? p : <b key={j}>{p.b}</b>))}
</div>
<div className="lr-sub">{relTime(a.time)}</div>
</div>
<div className="lr-sub">{relTime(a.time)}</div>
</div>
</div>
))}
))}
</ListGate>
</div>
</div>
</div>

View File

@ -0,0 +1,138 @@
/* The profile modal for SCORED candidates (rows from /candidate/scored/fetch),
used by Candidates.jsx. Distinct from CandidateProfile.jsx, which renders
inbox-derived candidate profiles (userId, interviews, notes, feedback) for
TalentPool. The two data shapes share almost no fields, hence two modals. */
import { useState } from 'react'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
const TABS = ['Overview', 'Scoring', 'File']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose, onAtsMatch }) {
const [tab, setTab] = useState('Overview')
const scored = c.scoringStatus === 'completed'
return (
<Modal
title="Candidate Profile"
subtitle={c.filename}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
</button>
<button className="btn btn-primary" onClick={onClose}>Close</button>
</>
}
>
<div className="profile-hero">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{c.name}</div>
<div className="ph-role">
{c.currentTitle ?? '—'}{c.currentCompany ? ` at ${c.currentCompany}` : ''}
</div>
<div className="ph-tags">
{c.scoringStatus && (scored ? <Badge className="b-green">Scored</Badge> : <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>)}
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
{c.experience != null && (
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
)}
</div>
</div>
{c.aiScore != null && (
<div style={{ textAlign: 'center' }}>
<ScoreChip score={c.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
</div>
)}
</div>
<div style={{ marginTop: 22 }}>
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
</div>
<div className="tab-pane active">
{tab === 'Overview' && (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Scored For</div><div className="iv">{jobTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Current Title</div><div className="iv">{c.currentTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany ?? '—'}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience != null ? `${c.experience} years` : '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source}</div></div>
<div className="info-item"><div className="il">Added On</div><div className="iv">{c.applied ? fmtDate(c.applied) : '—'}</div></div>
</div>
{scored && (
<>
<div style={LABEL}>Matched Skills</div>
<div className="k-tags">
{c.matchedSkills.length
? c.matchedSkills.map((s) => <span className="tag" key={s}>{s}</span>)
: <span className="text-muted"></span>}
</div>
</>
)}
</>
)}
{tab === 'Scoring' && (
scored ? (
<>
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div>
<p className="text-muted" style={{ marginBottom: 18 }}>{c.critique ?? '—'}</p>
<div className="form-section-title" style={{ marginTop: 0 }}>
Matched Skills ({c.matchedSkills.length})
</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{c.matchedSkills.length
? c.matchedSkills.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
))
: <span className="text-muted"></span>}
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Missing Skills ({c.missingSkills.length})
</div>
<div className="k-tags">
{c.missingSkills.length
? c.missingSkills.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
))
: <span className="text-muted">None full match</span>}
</div>
</>
) : (
<EmptyState icon="target" title="Not scored">
{c.errorMessage ?? 'This CV could not be processed.'}
</EmptyState>
)
)}
{tab === 'File' && (
<div className="info-grid">
<div className="info-item"><div className="il">File Name</div><div className="iv">{c.filename}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source}</div></div>
{c.inboxMessageId && (
<div className="info-item"><div className="il">Inbox Message</div><div className="iv">{c.inboxMessageId}</div></div>
)}
{!scored && (
<>
<div className="info-item"><div className="il">Error</div><div className="iv">{c.errorCode ?? '—'}</div></div>
<div className="info-item"><div className="il">Detail</div><div className="iv">{c.errorMessage ?? '—'}</div></div>
</>
)}
</div>
)}
</div>
</Modal>
)
}

View File

@ -1,4 +1,4 @@
/* ============================================================
/* ============================================================
Talent Pool the prototype's card grid, now fed by GET /candidate/fetch.
The layout, the toolbar, the card and the 8-tab profile modal are the
@ -82,6 +82,10 @@ function merge(row, template) {
status: stage,
currentTitle: title || template.currentTitle,
jobTitle: title || template.jobTitle,
// Real ATS score (scoring engine, joined server-side by inbox message)
// wins over the seed placeholder; recommendation follows it.
aiScore: row.ai_score ?? template.aiScore,
recommendation: row.recommendation ?? template.recommendation,
}
}