Merge pull request 'candidates are arrigving' (#94) from Edit_Job_Profile into dev_main
Deploy to S3 / deploy (push) Successful in 39s Details

Reviewed-on: #94
Interview_Delay
ahmed.mujtaba 2026-09-15 13:04:40 +00:00
commit cf9cf7ca8e
25 changed files with 2215 additions and 367 deletions

View File

@ -13,6 +13,12 @@ Django-shaped aliases (same behaviour, different names):
python alembic_setup.py upgrade # apply versions/*.py (like migrate) python alembic_setup.py upgrade # apply versions/*.py (like migrate)
python alembic_setup.py stamp -r f3a7e5b34c86 # bookmark only; no DDL python alembic_setup.py stamp -r f3a7e5b34c86 # bookmark only; no DDL
One command for every pending change schema from all models, manual SQL, RBAC:
python alembic_setup.py sync --dry-run > review.sql # print the SQL, run nothing
python alembic_setup.py sync # apply it (drops skipped)
python alembic_setup.py sync --allow-drops # also drop tables/columns/indexes
Named `alembic_setup` rather than `alembic` because `backend/` is on `sys.path`, Named `alembic_setup` rather than `alembic` because `backend/` is on `sys.path`,
so a module called `alembic.py` would shadow the installed package. so a module called `alembic.py` would shadow the installed package.
""" """
@ -21,6 +27,7 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import io
import logging import logging
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -32,6 +39,7 @@ from alembic import command
from alembic.autogenerate import compare_metadata, produce_migrations, render_python_code from alembic.autogenerate import compare_metadata, produce_migrations, render_python_code
from alembic.config import Config from alembic.config import Config
from alembic.operations import Operations from alembic.operations import Operations
from alembic.operations import ops as alembic_ops
from alembic.runtime.migration import MigrationContext from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory from alembic.script import ScriptDirectory
from alembic.script.revision import ResolutionError from alembic.script.revision import ResolutionError
@ -40,7 +48,15 @@ from alembic.util.exc import CommandError
from sqlalchemy import MetaData, text from sqlalchemy import MetaData, text
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
from db_setup import BASE_DIR, Base, close_db, database_url, get_engine, get_settings from db_setup import (
BASE_DIR,
Base,
close_db,
create_schemas,
database_url,
get_engine,
get_settings,
)
logger = logging.getLogger("db.alembic") logger = logging.getLogger("db.alembic")
@ -182,6 +198,7 @@ def config(connection: Connection | None = None) -> Config:
VERSION_TABLE = "alembic_version" VERSION_TABLE = "alembic_version"
MANUAL_TABLE = "manual_migrations" MANUAL_TABLE = "manual_migrations"
RBAC_LEDGER_TABLE = "rbac_sync_ledger" # mirrors role.plugins.RBAC_LEDGER_TABLE
def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to: Any) -> bool: def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to: Any) -> bool:
@ -194,7 +211,7 @@ def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to
return False return False
if type_ != "table": if type_ != "table":
return True return True
if name in (VERSION_TABLE, MANUAL_TABLE): # migration bookkeeping; never ours to alter if name in (VERSION_TABLE, MANUAL_TABLE, RBAC_LEDGER_TABLE): # bookkeeping; never ours to alter
return False return False
return not s.db_schemas or (obj.schema or s.db_default_schema) in s.db_schemas return not s.db_schemas or (obj.schema or s.db_default_schema) in s.db_schemas
@ -334,24 +351,56 @@ async def downgrade(revision: str = "-1") -> None:
logger.info("downgraded to %s", revision) logger.info("downgraded to %s", revision)
def _apply_upgrade_ops(connection: Connection) -> int: _DESTRUCTIVE_OPS = (
"""Apply ORM→DB diffs in-process without writing a revision file.""" alembic_ops.DropTableOp,
alembic_ops.DropColumnOp,
alembic_ops.DropIndexOp,
alembic_ops.DropConstraintOp,
)
def _pending_ops(connection: Connection) -> list[Any]:
"""The ORM→DB diff as a flat list of Alembic operations, in revision-file order."""
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"} opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
ctx = MigrationContext.configure(connection, opts=opts) ctx = MigrationContext.configure(connection, opts=opts)
script = produce_migrations(ctx, target_metadata()) script = produce_migrations(ctx, target_metadata())
if script.upgrade_ops.is_empty(): flat: list[Any] = []
return 0
operations = Operations(ctx) def walk(elem: Any) -> None:
applied = 0
stack = [script.upgrade_ops]
while stack:
elem = stack.pop(0)
if hasattr(elem, "ops"): if hasattr(elem, "ops"):
stack.extend(elem.ops) for child in elem.ops:
walk(child)
else: else:
operations.invoke(elem) flat.append(elem)
applied += 1
return applied walk(script.upgrade_ops)
return flat
def _invoke_ops(connection: Connection, ops: list[Any]) -> int:
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
operations = Operations(MigrationContext.configure(connection, opts=opts))
for op in ops:
operations.invoke(op)
return len(ops)
def _render_sql(ops: list[Any]) -> str:
"""Render operations as PostgreSQL DDL without touching a database (Alembic offline mode)."""
buf = io.StringIO()
ctx = MigrationContext.configure(
dialect_name="postgresql",
opts={"as_sql": True, "output_buffer": buf, "literal_binds": True},
)
operations = Operations(ctx)
for op in ops:
operations.invoke(op)
return buf.getvalue().strip()
def _apply_upgrade_ops(connection: Connection) -> int:
"""Apply ORM→DB diffs in-process without writing a revision file."""
return _invoke_ops(connection, _pending_ops(connection))
async def apply_model_drift() -> bool: async def apply_model_drift() -> bool:
@ -458,6 +507,107 @@ async def run_manual_sql() -> None:
logger.info("applied manual migration %s", path.name) logger.info("applied manual migration %s", path.name)
async def _pending_manual_files() -> list[str]:
"""Manual SQL files this database has not recorded yet. Read-only."""
files = sorted(p.name for p in (MIGRATIONS / "manual").glob("*.sql") if p.is_file())
schema = get_settings().db_default_schema
table = f"{schema}.{MANUAL_TABLE}" if schema else MANUAL_TABLE
async with get_engine().connect() as conn:
driver = (await conn.get_raw_connection()).driver_connection
if await driver.fetchval("SELECT to_regclass($1)", table) is None:
return files
applied = {r["filename"] for r in await driver.fetch(f"SELECT filename FROM {table}")}
return [f for f in files if f not in applied]
async def run_rbac_sync(*, dry_run: bool = False) -> list[str]:
"""Bring permission tags, system roles and bundles up to role/plugins.py.
Returns the SQL it ran (or would run). Empty once the database matches the code,
so running it on every boot is cheap.
"""
from role.plugins import RbacState, build_rbac_sql # local import: pulls in the app models
schema = get_settings().db_default_schema or None
prefix = f"{schema}." if schema else ""
async with get_engine().connect() as conn:
driver = (await conn.get_raw_connection()).driver_connection
if await driver.fetchval("SELECT to_regclass($1)", f"{prefix}roles") is None:
logger.info("rbac sync skipped: roles table does not exist yet")
return []
has_ledger = (
await driver.fetchval("SELECT to_regclass($1)", f"{prefix}{RBAC_LEDGER_TABLE}")
) is not None
async def column(sql: str) -> frozenset[Any]:
return frozenset(tuple(r) if len(r) > 1 else r[0] for r in await driver.fetch(sql))
state = RbacState(
tags=await column(f"SELECT tag_name FROM {prefix}permission_tags"),
roles=await column(f"SELECT role_name FROM {prefix}roles"),
bundles=await column(f"SELECT name FROM {prefix}permissions"),
ledger=(
await column(f"SELECT kind, key FROM {prefix}{RBAC_LEDGER_TABLE}")
if has_ledger
else frozenset()
),
)
statements = build_rbac_sql(state, schema=schema)
if statements and not dry_run:
async with driver.transaction():
for statement in statements:
await driver.execute(statement)
logger.info("rbac sync applied %s statement(s)", len(statements))
return statements
async def sync(*, dry_run: bool = False, allow_drops: bool = False) -> str:
"""Every pending change in one pass: schema drift from all models, manual SQL, RBAC.
Returns the SQL as one reviewable script. `dry_run` executes nothing. Destructive
schema operations are listed as comments and skipped unless `allow_drops`.
"""
header = ["-- DRY RUN: nothing was executed"] if dry_run else []
# Rendered DDL names reflected tables without a schema, as `_run` resolves them.
header.append(f'SET search_path TO "{get_settings().db_default_schema or "public"}", public;')
out = list(header)
async with _lock():
pending: list[Any] = []
if await _schema_is_empty():
if dry_run:
out.append("-- empty database: sync would create every model table from metadata")
else:
await bootstrap_empty()
out.append("-- empty database: created every model table from metadata")
else:
pending = await _run(_pending_ops)
kept = [op for op in pending if allow_drops or not isinstance(op, _DESTRUCTIVE_OPS)]
skipped = [op for op in pending if not allow_drops and isinstance(op, _DESTRUCTIVE_OPS)]
if kept:
out += [f"-- schema: {len(kept)} operation(s) from the models", _render_sql(kept)]
if not dry_run:
await _run(lambda c: _invoke_ops(c, kept))
if skipped:
rendered = "\n".join(f"-- {line}" for line in _render_sql(skipped).splitlines())
header = f"-- skipped {len(skipped)} destructive operation(s); rerun with --allow-drops"
out += [header, rendered]
manual = await _pending_manual_files()
if manual:
verb = "pending (RBAC plan below assumes they did not run)" if dry_run else "applied"
out.append(f"-- manual SQL {verb}:\n" + "\n".join(f"-- {name}" for name in manual))
if not dry_run:
await run_manual_sql()
rbac = await run_rbac_sync(dry_run=dry_run)
if rbac:
out += [f"-- rbac: {len(rbac)} statement(s) from role/plugins.py", *rbac]
if len(out) == len(header):
out.append("-- database is in sync with the code")
return "\n\n".join(out) + "\n"
@asynccontextmanager @asynccontextmanager
async def _lock() -> AsyncIterator[None]: async def _lock() -> AsyncIterator[None]:
"""Advisory lock, so only one worker migrates when several boot at once.""" """Advisory lock, so only one worker migrates when several boot at once."""
@ -492,6 +642,10 @@ async def migrate(*, autogen: bool | None = None, message: str = "auto") -> None
# ran above. Log and continue so the API can finish booting. # ran above. Log and continue so the API can finish booting.
logger.exception("ORM drift apply failed; continuing boot: %s", exc) logger.exception("ORM drift apply failed; continuing boot: %s", exc)
await run_manual_sql() await run_manual_sql()
try:
await run_rbac_sync()
except Exception as exc:
logger.exception("RBAC sync failed; continuing boot: %s", exc)
logger.info("database at revision %s", await current()) logger.info("database at revision %s", await current())
@ -510,10 +664,15 @@ def main(argv: Sequence[str] | None = None) -> None:
"current", "current",
"head", "head",
"stamp", "stamp",
"sync",
], ],
) )
parser.add_argument("-m", "--message", default="auto", help="revision message") parser.add_argument("-m", "--message", default="auto", help="revision message")
parser.add_argument("-r", "--revision", help="target revision") parser.add_argument("-r", "--revision", help="target revision")
parser.add_argument("--dry-run", action="store_true", help="sync: print the SQL, run nothing")
parser.add_argument(
"--allow-drops", action="store_true", help="sync: also drop tables, columns, indexes"
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s")
@ -539,6 +698,10 @@ def main(argv: Sequence[str] | None = None) -> None:
print(head()) print(head())
elif args.command == "stamp": elif args.command == "stamp":
await stamp(args.revision or "head") await stamp(args.revision or "head")
elif args.command == "sync":
if not args.dry_run:
await create_schemas()
print(await sync(dry_run=args.dry_run, allow_drops=args.allow_drops), end="")
finally: finally:
await close_db() await close_db()

View File

@ -1359,7 +1359,7 @@ class Inbox_Messages(SQLModel, table=True):
return row return row
@classmethod @classmethod
async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids) -> dict[str, int]: async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids,search=None,top=None,limit=None) -> dict[str, int]:
"""Resolve {job_post_id: applicant_count} for a page of rows in a single query. """Resolve {job_post_id: applicant_count} for a page of rows in a single query.
Counts Inbox_Messages rows, not Inbox rows: one message fans out to several Counts Inbox_Messages rows, not Inbox rows: one message fans out to several
@ -1373,6 +1373,12 @@ class Inbox_Messages(SQLModel, table=True):
.where(cls.assigned_job_post_id.in_(uids)) .where(cls.assigned_job_post_id.in_(uids))
.group_by(cls.assigned_job_post_id) .group_by(cls.assigned_job_post_id)
) )
if search:
result = result.where(cls.message_from.ilike(f"%{search}%"))
if top:
result = result.limit(top)
if limit:
result = result.limit(limit)
return {str(job_id): int(n) for job_id, n in result.all()} return {str(job_id): int(n) for job_id, n in result.all()}
@classmethod @classmethod
@ -2152,6 +2158,109 @@ class AtsResults(SQLModel, table=True):
grouped.setdefault(row.form_data_id, []).append(row) grouped.setdefault(row.form_data_id, []).append(row)
return grouped return grouped
@classmethod
async def latest_per_candidate_for_job(
cls, session: AsyncSession, job_post_id, search=None, top=None, limit=None, skip=None,
):
"""Suggested candidates for one job: the newest score per person, best first.
A person is whichever identity the row carries form_data_id,
candidate_id or user_id (one is set at a time) so DISTINCT ON their
COALESCE, newest created_at winning. Rows carrying none are skipped.
`search` matches the candidate's name, email, title or company; `top` and
`limit` cap how many rows come back; `skip` is the page offset. Returns
(row, user_name, user_email, form_name, form_email, candidate).
"""
from g_sheet.models import FormData
from job.candidate.models import Candidates
jid = cls._as_uuid(job_post_id)
if jid is None:
return []
identity = func.coalesce(cls.form_data_id, cls.candidate_id, cls.user_id)
latest = (
select(cls.id)
.where(cls.job_post_id == jid, identity.is_not(None))
.distinct(identity)
.order_by(identity.desc(), cls.created_at.desc())
.subquery()
)
statement = (
select(cls, Users.name, Users.email, FormData.name, FormData.candidate_email, Candidates)
.join(latest, cls.id == latest.c.id)
.outerjoin(Users, cls.user_id == Users.id)
.outerjoin(FormData, cls.form_data_id == FormData.id)
.outerjoin(Candidates, cls.candidate_id == Candidates.id)
.order_by(cls.overall_score.desc(), cls.created_at.desc(),cls.id.desc())
)
if search:
like = f"%{search.strip()}%"
statement = statement.where(
or_(
Users.name.ilike(like),
Users.email.ilike(like),
FormData.name.ilike(like),
FormData.candidate_email.ilike(like),
Candidates.candidate_name.ilike(like),
Candidates.candidate_email.ilike(like),
Candidates.job_title.ilike(like),
Candidates.current_company.ilike(like),
)
)
if skip:
statement = statement.offset(skip)
if top:
statement = statement.limit(top)
if limit:
statement = statement.limit(limit)
result = await session.execute(statement)
return list(result.all())
@classmethod
async def count_suggested_for_job(cls, session: AsyncSession, job_post_id, search=None) -> int:
"""How many suggested candidates match `search` — the paged list's total.
Same rows as latest_per_candidate_for_job before its offset and limit.
"""
from g_sheet.models import FormData
from job.candidate.models import Candidates
jid = cls._as_uuid(job_post_id)
if jid is None:
return 0
identity = func.coalesce(cls.form_data_id, cls.candidate_id, cls.user_id)
latest = (
select(cls.id)
.where(cls.job_post_id == jid, identity.is_not(None))
.distinct(identity)
.order_by(identity.desc(), cls.created_at.desc(),cls.id.desc())
.subquery()
)
statement = (
select(func.count())
.select_from(cls)
.join(latest, cls.id == latest.c.id)
.outerjoin(Users, cls.user_id == Users.id)
.outerjoin(FormData, cls.form_data_id == FormData.id)
.outerjoin(Candidates, cls.candidate_id == Candidates.id)
)
if search:
like = f"%{search.strip()}%"
statement = statement.where(
or_(
Users.name.ilike(like),
Users.email.ilike(like),
FormData.name.ilike(like),
FormData.candidate_email.ilike(like),
Candidates.candidate_name.ilike(like),
Candidates.candidate_email.ilike(like),
Candidates.job_title.ilike(like),
Candidates.current_company.ilike(like),
)
)
result = await session.execute(statement)
return int(result.scalar_one())
@classmethod @classmethod
async def job_ids_by_emails(cls, session: AsyncSession, emails) -> dict: async def job_ids_by_emails(cls, session: AsyncSession, emails) -> dict:
"""{email: {job_post_id, ...}} for any prior ATS score of these people.""" """{email: {job_post_id, ...}} for any prior ATS score of these people."""

View File

@ -153,7 +153,6 @@ class JobUpdate(BaseModel):
experience_min: int | None = None experience_min: int | None = None
experience_max: int | None = None experience_max: int | None = None
description: str | None = None description: str | None = None
current_recruiter_id: UUID | None = None
current_recruiter_ids: list[UUID] | None = None current_recruiter_ids: list[UUID] | None = None
hiring_manager_id: UUID | None = None hiring_manager_id: UUID | None = None
requisition_id: UUID | None = None requisition_id: UUID | None = None
@ -938,13 +937,16 @@ async def fetch_requisition_statuses(
@router.get("/jobs/status-history/fetch") @router.get("/jobs/status-history/fetch")
async def fetch_job_status_history( async def fetch_job_status_history(
job_post_id:str=Query(...), job_post_id:str=Query(...),
search: str | None = Query(None),
top: int | None = Query(10, ge=1, le=500),
limit: int | None = Query(None, ge=1),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""Who changed requisition_status on one job, from what, to what, and when.""" """Who changed requisition_status on one job, from what, to what, and when."""
try: try:
service=JobPost(session=session) service=JobPost(session=session)
data=await service.fetch_status_history(job_post_id) data=await service.fetch_status_history(job_post_id,search,top,limit)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException: except HTTPException:
raise raise
@ -984,6 +986,29 @@ async def fetch_jobs(
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.get("/jobs/profile/fetch")
async def fetch_job_profile(
job_post_id: str = Query(...),
search: str | None = Query(None),
top: int | None = Query(None, ge=1),
limit: int | None = Query(None, ge=1),
skip: int | None = Query(None, ge=0),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Job profile page: the job row, suggested candidates (newest ats_results row
per person, best score first) and the Suggested / Top Match header stats.
`total` is how many suggested candidates match `search`, across all pages."""
try:
service=JobPost(session=session)
data=await service.fetch_job_profile(job_post_id,current_user=current_user,search=search,top=top,limit=limit,skip=skip)
return JSONResponse(content={"data":data,"total":data["total"],"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/jobs/export") @router.get("/jobs/export")
async def export_jobs( async def export_jobs(
search: str | None = Query(None), search: str | None = Query(None),

View File

@ -15,7 +15,7 @@ JOB_ASSIGNMENT_ROLES = {
"hiring_manager": EnumRoles.HIRING_MANAGER, "hiring_manager": EnumRoles.HIRING_MANAGER,
} }
JOB_OWNER_COLUMN = { JOB_OWNER_COLUMN = {
"primary_recruiter": "current_recruiter_id", "primary_recruiter": "current_recruiter_ids",
"hiring_manager": "hiring_manager_id", "hiring_manager": "hiring_manager_id",
} }

View File

@ -1143,6 +1143,28 @@ class Candidates(SQLModel, table=True):
) )
return result.scalars().first() return result.scalars().first()
@classmethod
async def latest_completed_for_job_by_emails(cls, session: AsyncSession, job_id, emails) -> dict:
"""{lower(email): newest completed row} against one job — the batch form of
get_completed_by_email_job, for scores whose identity is a user or form row."""
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
jid = cls._as_uuid(job_id)
if not lowers or jid is None:
return {}
result = await session.execute(
select(cls)
.where(
func.lower(cls.candidate_email).in_(lowers),
cls.job_id == jid,
cls.status == "completed",
)
.order_by(cls.updated_at.desc())
)
out = {}
for row in result.scalars():
out.setdefault((row.candidate_email or "").strip().lower(), row)
return out
@classmethod @classmethod
async def list_by_emails(cls, session: AsyncSession, emails): async def list_by_emails(cls, session: AsyncSession, emails):
"""Scored `candidates` rows for these addresses (ATS, not pipeline stage).""" """Scored `candidates` rows for these addresses (ATS, not pipeline stage)."""
@ -1279,9 +1301,7 @@ class Interviews(SQLModel, table=True):
interview_type: str = Field(default="") interview_type: str = Field(default="")
interview_status: str = Field(default="") interview_status: str = Field(default="")
inbox_id: int | None = Field(default=None, foreign_key="inbox.id") inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
# Optional denorm so Recruiter Hub can join interviews → job_posts.current_recruiter_id
# without walking inbox. Filled on create from the application's assigned job;
# migration 011 added the columns. user_id is the candidate, not the recruiter.
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id") job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
graph_event_id: str | None = Field(default=None) graph_event_id: str | None = Field(default=None)
@ -1342,9 +1362,6 @@ class Interviews(SQLModel, table=True):
@classmethod @classmethod
def scoped_to_recruiter(cls, statement, recruiter_id): def scoped_to_recruiter(cls, statement, recruiter_id):
"""Restrict an Interviews select to the recruiter who owns the job. """Restrict an Interviews select to the recruiter who owns the job.
COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id)
job_posts.current_recruiter_id. Interviews with no job drop out.
""" """
from inbox.models import Inbox, Inbox_Messages from inbox.models import Inbox, Inbox_Messages
from job.job_post.models import JobPosts from job.job_post.models import JobPosts

View File

@ -60,11 +60,7 @@ class JobPosts(SQLModel, table=True):
department: str = Field(default="", sa_column_kwargs={"server_default": ""}) department: str = Field(default="", sa_column_kwargs={"server_default": ""})
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"}) vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
# Who is working the req now (swappable). History lives in job_assignments
# with assignment_role=primary_recruiter; this column is the first / primary
# pointer so existing joins keep working. current_recruiter_ids is the full
# list (UUID strings) so more than one recruiter can sit on the same job.
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
current_recruiter_ids: list[str] = Field( current_recruiter_ids: list[str] = Field(
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"}, default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"},
) )
@ -94,59 +90,36 @@ class JobPosts(SQLModel, table=True):
@staticmethod @staticmethod
def recruiter_ids_of(row) -> list[str]: def recruiter_ids_of(row) -> list[str]:
"""UUID strings currently assigned as recruiters on a job row or mapping. """The job's recruiters as UUID strings — current_recruiter_ids, nothing else.
Prefers current_recruiter_ids; falls back to current_recruiter_id so a Strings because every consumer keys names off str(uuid). Works on a JobPosts
row that has not been backfilled still maps to one person. row and on a RowMapping from fetch_job_stats; both allow attribute access.
""" """
if isinstance(row, dict): return [str(i) for i in (getattr(row, "current_recruiter_ids", None) or [])]
raw = row.get("current_recruiter_ids")
fallback = row.get("current_recruiter_id")
else:
raw = getattr(row, "current_recruiter_ids", None)
fallback = getattr(row, "current_recruiter_id", None)
out: list[str] = []
seen: set[str] = set()
for item in raw or []:
uid = JobPosts._as_uuid(item)
if uid is None:
continue
key = str(uid)
if key in seen:
continue
seen.add(key)
out.append(key)
if not out:
uid = JobPosts._as_uuid(fallback)
if uid is not None:
out.append(str(uid))
return out
@classmethod @classmethod
def has_recruiter(cls, recruiter_id): def has_recruiter(cls, recruiter_id):
"""SQL: this recruiter is the primary pointer or in current_recruiter_ids.""" """SQL: this recruiter is in current_recruiter_ids."""
uid = recruiter_id if isinstance(recruiter_id, uuid.UUID) else cls._as_uuid(recruiter_id) uid = recruiter_id if isinstance(recruiter_id, uuid.UUID) else cls._as_uuid(recruiter_id)
if uid is None: if uid is None:
return false() return false()
return or_( return cls.current_recruiter_ids.contains([str(uid)])
cls.current_recruiter_id == uid,
cls.current_recruiter_ids.contains([str(uid)]),
)
@classmethod @classmethod
def no_recruiters(cls): def no_recruiters(cls):
"""SQL: neither the pointer nor the JSON list names anyone.""" """SQL: current_recruiter_ids names nobody — NULL or [].
return and_(
cls.current_recruiter_id.is_(None), coalesce covers both, so this must not be ANDed with an IS NULL test:
func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0, an empty list is not NULL and would fall out of the result.
) """
return func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0
@classmethod @classmethod
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str): async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
uid = cls._as_uuid(record_id) uid = cls._as_uuid(record_id)
if uid is None: if uid is None:
return None return None
result = await session.execute(select(cls).where(cls.id == uid)) result = await session.execute(select(cls).where(cls.id == uid).order_by(cls.created_at.desc(),cls.id.desc()))
return result.scalars().first() return result.scalars().first()
@classmethod @classmethod
@ -507,9 +480,9 @@ class JobPosts(SQLModel, table=True):
.subquery("job_reapplied") .subquery("job_reapplied")
) )
# Alias so this join does not collide with the Users join inside # No recruiter join here: current_recruiter_ids is a JSONB array, so a join
# the manual-upload subquery above. # on it would emit one stats row per recruiter and multiply the counts.
Recruiter=aliased(Users) # The service resolves recruiter names from that column instead.
statement = ( statement = (
select( select(
cls.id.label("job_post_id"), cls.id.label("job_post_id"),
@ -517,10 +490,8 @@ class JobPosts(SQLModel, table=True):
cls.department, cls.department,
cls.location, cls.location,
cls.requisition_status, cls.requisition_status,
cls.current_recruiter_id,
cls.current_recruiter_ids, cls.current_recruiter_ids,
cls.created_at, cls.created_at,
Recruiter.name.label("recruiter_name"),
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"), func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
func.coalesce(stats.c.shortlisting, 0).label("shortlisting"), func.coalesce(stats.c.shortlisting, 0).label("shortlisting"),
func.coalesce(stats.c.screened, 0).label("screened"), func.coalesce(stats.c.screened, 0).label("screened"),
@ -536,7 +507,6 @@ class JobPosts(SQLModel, table=True):
.select_from(cls) .select_from(cls)
.outerjoin(stats, stats.c.job_post_id == cls.id) .outerjoin(stats, stats.c.job_post_id == cls.id)
.outerjoin(reapplied_stats, reapplied_stats.c.job_post_id == cls.id) .outerjoin(reapplied_stats, reapplied_stats.c.job_post_id == cls.id)
.outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id)
.where(cls.is_deleted == False) # noqa: E712 .where(cls.is_deleted == False) # noqa: E712
) )
if active_only: if active_only:
@ -891,15 +861,22 @@ class JobPostStatusHistory(SQLModel, table=True):
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@classmethod @classmethod
async def fetch_by_job(cls, session: AsyncSession, job_post_id): async def fetch_by_job(cls, session: AsyncSession, job_post_id,search=None,top=None,limit=None):
uid = JobPosts._as_uuid(job_post_id) uid = JobPosts._as_uuid(job_post_id)
if uid is None: if uid is None:
return [] return []
result = await session.execute( statement = (
select(cls) select(cls)
.where(cls.job_post_id == uid) .where(cls.job_post_id == uid)
.order_by(cls.created_at.desc(), cls.id.desc()) .order_by(cls.created_at.desc(), cls.id.desc())
) )
if search:
statement = statement.where(cls.from_status.ilike(f"%{search}%") or cls.to_status.ilike(f"%{search}%"))
if top:
statement = statement.limit(top)
if limit:
statement = statement.limit(limit)
result = await session.execute(statement)
return list(result.scalars().all()) return list(result.scalars().all())

View File

@ -281,3 +281,75 @@ async def list_buffer_channels() -> list[dict]:
"organization_name": org.get("name"), "organization_name": org.get("name"),
}) })
return channels return channels
# ats_results.band labels, best first — the same cut-offs CandidateView._recommendation writes.
MATCH_BANDS = ("Strong Match", "Potential Match", "Weak Match")
TOP_MATCH_BAND = MATCH_BANDS[0]
def suggested_source(inbox_id, form_data_id, candidate_source=None) -> str:
"""Where a suggested candidate's score came from: inbox | form | upload | bank."""
if inbox_id is not None:
return "inbox"
if form_data_id is not None:
return "form"
return (candidate_source or "").strip() or "upload"
def _skill_key(value) -> str:
return " ".join(re.sub(r"[^a-z0-9+#]+", " ", str(value or "").lower()).split())
def optional_skill_hits(optional_skills, matched_keywords) -> list[str]:
"""Job optional skills the candidate's matched keywords cover, in the job's order.
A hit is an exact normalized match, or one side containing the other as whole
words ("Salesforce" covers "CRM (Salesforce)"). Keywords are verified against the
resume upstream, so this only has to line up two spellings of the same skill.
"""
keys = [k for k in (_skill_key(m) for m in matched_keywords or []) if k]
hits = []
for skill in optional_skills or []:
target = _skill_key(skill)
if not target or skill in hits:
continue
padded = f" {target} "
if any(k == target or f" {k} " in padded or padded in f" {k} " for k in keys):
hits.append(skill)
return hits
def suggested_summary(candidates) -> dict:
"""Header stats for a job's suggested candidates: count, top-band count, best score."""
bands = {band: 0 for band in MATCH_BANDS}
scores = []
for c in candidates or []:
if c.get("band") in bands:
bands[c["band"]] += 1
if c.get("match_score") is not None:
scores.append(c["match_score"])
return {
"suggested": len(candidates or []),
"top_match": bands[TOP_MATCH_BAND],
"top_score": max(scores) if scores else None,
"bands": bands,
}
def job_people_of(row, people) -> dict:
"""One job's slice of a page-wide {"recruiters": …, "hiring_manager": …} lookup.
Users.job_people resolves every id on the page in two queries; this picks out
the names belonging to one row, keeping the two roles in their own maps.
"""
from job.job_post.models import JobPosts
recruiters = (people or {}).get("recruiters") or {}
managers = (people or {}).get("hiring_manager") or {}
manager_id = getattr(row, "hiring_manager_id", None)
key = str(manager_id) if manager_id else None
return {
"recruiters": {rid: recruiters.get(rid) for rid in JobPosts.recruiter_ids_of(row)},
"hiring_manager": {key: managers[key]} if key and key in managers else {},
}

View File

@ -21,15 +21,20 @@ def serialize_job_post_title(row) -> dict:
def _recruiter_payload(row, names=None): def _recruiter_payload(row, names=None):
"""List of recruiter ids plus mapped names; first id stays the legacy pointer.""" """Recruiter ids plus their mapped names, for the payloads that still send the
flat shape (serialize_job_post, serialize_job_stats)."""
names = names or {} names = names or {}
ids = JobPosts.recruiter_ids_of(row) ids = JobPosts.recruiter_ids_of(row)
mapped = [names.get(i) for i in ids] mapped = [names.get(i) for i in ids]
first = ids[0] if ids else None # recruiter_name is the singular legacy field: the first id that resolved.
recruiter_name = None
for n in mapped:
if n:
recruiter_name = n
break
return { return {
"current_recruiter_id": first,
"current_recruiter_ids": ids, "current_recruiter_ids": ids,
"recruiter_name": next((n for n in mapped if n), None), "recruiter_name": recruiter_name,
"recruiter_names": [n for n in mapped if n], "recruiter_names": [n for n in mapped if n],
"recruiters": [{"id": i, "name": names.get(i)} for i in ids], "recruiters": [{"id": i, "name": names.get(i)} for i in ids],
} }
@ -69,28 +74,31 @@ def serialize_job_post(row, *, names=None) -> dict:
} }
def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict: def serialize_job_row(row, *, people=None, applicant_count=0) -> dict:
"""Requisition view of a job post, for the Jobs screen. """Requisition view of a job post, for the Jobs screen and the job profile.
Deliberately separate from serialize_job_post: that payload is shared by the Deliberately separate from serialize_job_post: that payload is shared by the
inbox, candidate and matching paths. department is the one shared field inbox, candidate and matching paths. department is the one shared field
talent-pool filters key off it on attached job_posts. talent-pool filters key off it on attached job_posts.
`people` is Users.job_people's shape — {"recruiters": {id: name},
"hiring_manager": {id: name}} so the two roles stay apart in the response.
""" """
req = getattr(row, "requisition", None) req = getattr(row, "requisition", None)
payload = _recruiter_payload(row, names) people = people or {}
if recruiter_name and not payload["recruiter_name"]: recruiters = people.get("recruiters") or {}
payload["recruiter_name"] = recruiter_name hiring_manager = people.get("hiring_manager") or None
return { return {
"id": str(row.id), "id": str(row.id),
"title": row.title, "title": row.title,
"department": row.department_ref.name if row.department_ref else None, # The linked department wins; the free-text column is the fallback, and is
# still what fetch_jobs filters on, so reads and filters agree.
"department": (row.department_ref.name if row.department_ref else None) or row.department or None,
"department_id": str(row.department_id) if row.department_id else None, "department_id": str(row.department_id) if row.department_id else None,
"location": row.location, "location": row.location,
"employment_type": row.employment_type, "employment_type": row.employment_type,
"vacancies": row.vacancies, "vacancies": row.vacancies,
"platform": row.platform or None, "platform": row.platform or None,
# Two different lifecycles, never conflate: requisition_status is hiring
# (open/closed/on_hold), status is Buffer publishing (draft/scheduled/...).
"requisition_status": row.requisition_status, "requisition_status": row.requisition_status,
"status": row.status, "status": row.status,
"experience_min": row.experience_min, "experience_min": row.experience_min,
@ -101,9 +109,6 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
"description": row.description, "description": row.description,
"is_active": row.is_active, "is_active": row.is_active,
"closed_at": row.closed_at.isoformat() if row.closed_at else None, "closed_at": row.closed_at.isoformat() if row.closed_at else None,
**payload,
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
"hiring_manager_name": hiring_manager_name,
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None, "requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
"requisition_title": req.position_title if req else None, "requisition_title": req.position_title if req else None,
"requisition_department": req.department if req else None, "requisition_department": req.department if req else None,
@ -112,6 +117,8 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
"created_by_name": row.user.name if getattr(row, "user", None) else None, "created_by_name": row.user.name if getattr(row, "user", None) else None,
"created_at": row.created_at.isoformat() if row.created_at 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, "updated_at": row.updated_at.isoformat() if row.updated_at else None,
"recruiters": recruiters,
"hiring_manager": hiring_manager,
} }
@ -157,3 +164,30 @@ def serialize_status_history(row, *, changed_by_name=None) -> dict:
"actor_kind": row.actor_kind, "actor_kind": row.actor_kind,
"created_at": row.created_at.isoformat() if row.created_at else None, "created_at": row.created_at.isoformat() if row.created_at else None,
} }
def serialize_suggested_candidate(row, *, name=None, email=None, candidate=None, source=None, optional_matched=None) -> dict:
"""One suggested candidate on the job profile: the newest ats_results row for a
person, with profile fields from its `candidates` row when one exists."""
score = row.overall_score
return {
"id": str(row.id),
"user_id": str(row.user_id) if row.user_id else None,
"candidate_id": str(row.candidate_id) if row.candidate_id else None,
"form_data_id": str(row.form_data_id) if row.form_data_id else None,
"inbox_id": row.inbox_id,
"name": name or (candidate.candidate_name if candidate else None) or email or None,
"email": email or (candidate.candidate_email if candidate else None) or None,
"current_title": candidate.job_title if candidate else None,
"current_company": candidate.current_company if candidate else None,
"years_experience": candidate.years_experience if candidate else None,
"match_score": round(score) if score is not None else None,
"band": row.band or None,
"matched_keywords": list(candidate.matched_keywords or []) if candidate else [],
"missing_keywords": list(candidate.missing_keywords or []) if candidate else [],
"optional_matched": list(optional_matched or []),
"summary": (candidate.summary_critique if candidate else None) or row.professional_summary or None,
"source": source,
"scored_candidate_id": str(candidate.id) if candidate else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}

View File

@ -1,3 +1,6 @@
from typing import Any
from datetime import date, time from datetime import date, time
import logging import logging
import os import os
@ -11,23 +14,28 @@ from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from inbox.models import Inbox_Messages from inbox.models import AtsResults,Inbox_Messages
from job.assignment.views import Assignment from job.assignment.views import Assignment
from job.candidate.models import Candidates
from job.job_post.enums import RequisitionStatus from job.job_post.enums import RequisitionStatus
from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform
from role.models import EnumRoles from role.models import EnumRoles
from users.models import Users from users.models import Users
from job.job_post.plugins import ( from job.job_post.plugins import (
BufferError, BufferError,
job_people_of,
create_buffer_post, create_buffer_post,
list_buffer_channels, list_buffer_channels,
local_status, local_status,
normalize_platform, normalize_platform,
optional_skill_hits,
parse_buffer_datetime, parse_buffer_datetime,
render_job_post, render_job_post,
resolve_channel, resolve_channel,
suggested_source,
suggested_summary,
) )
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history, serialize_suggested_candidate
load_dotenv() load_dotenv()
logger=logging.getLogger("job.job_post") logger=logging.getLogger("job.job_post")
@ -41,28 +49,20 @@ MAX_JOB_IMAGE_BYTES=5*1024*1024
def _payload_recruiter_ids(payload): def _payload_recruiter_ids(payload):
"""Prefer current_recruiter_ids; fall back to current_recruiter_id. None = omitted.""" """None = the client did not send recruiters, so leave them alone.
has_list="current_recruiter_ids" in payload and payload.get("current_recruiter_ids") is not None [] = the client sent an empty list, so remove every recruiter.
has_one="current_recruiter_id" in payload
if has_list:
raw=payload.get("current_recruiter_ids") or []
if not isinstance(raw,(list,tuple)):
raw=[raw]
ids=list(raw)
if not ids and has_one and payload.get("current_recruiter_id") not in (None,""):
ids=[payload.get("current_recruiter_id")]
return ids
if has_one:
raw=payload.get("current_recruiter_id")
return [] if raw in (None,"") else [raw]
return None
Checks for the key rather than truthiness: an empty list is falsy, and
reading it as "not sent" made the last recruiter impossible to remove.
"""
if "current_recruiter_ids" not in payload:
return None
return payload.get("current_recruiter_ids") or []
def _recruiter_fields(users): def _recruiter_fields(users):
ids=[str(u.id) for u in users] ids=[str(u.id) for u in users]
return { return {
"current_recruiter_ids": ids, "current_recruiter_ids": ids,
"current_recruiter_id": users[0].id if users else None,
} }
@ -93,7 +93,6 @@ class JobPostCreate(BaseModel):
scheduler_date: date | None = None scheduler_date: date | None = None
due_at: str | None = None due_at: str | None = None
hiring_manager_id: UUID | None = None hiring_manager_id: UUID | None = None
current_recruiter_id: UUID | None = None
current_recruiter_ids: list[UUID] | None = None current_recruiter_ids: list[UUID] | None = None
requisition_id: UUID | None = None requisition_id: UUID | None = None
@ -376,12 +375,12 @@ class JobPost:
async def fetch_requisition_statuses(self): async def fetch_requisition_statuses(self):
return RequisitionStatus.as_list() return RequisitionStatus.as_list()
async def fetch_status_history(self,job_post_id): async def fetch_status_history(self,job_post_id,search=None,top=None,limit=None):
job=await JobPosts.get_job_post_by_id(self.session,job_post_id) job=await JobPosts.get_job_post_by_id(self.session,job_post_id)
if not job or job.is_deleted: if not job or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found") raise HTTPException(status_code=404,detail="Job post not found")
rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id) rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id,search,top,limit)
names=await Users.names_by_ids(self.session,[r.changed_by for r in rows]) names=await Users.names_by_ids(self.session,[r.changed_by for r in rows],search,top,limit)
return [ return [
serialize_status_history(r,changed_by_name=names.get(str(r.changed_by))) serialize_status_history(r,changed_by_name=names.get(str(r.changed_by)))
for r in rows for r in rows
@ -404,32 +403,81 @@ class JobPost:
employment_type=employment_type,hiring_manager_id=hm_uid, employment_type=employment_type,hiring_manager_id=hm_uid,
restrict_ids=restrict, restrict_ids=restrict,
) )
names=await Users.names_by_ids( people=await Users.job_people(
self.session, self.session,
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)]+[r.hiring_manager_id for r in rows], [uid for r in rows for uid in JobPosts.recruiter_ids_of(r)],
[r.hiring_manager_id for r in rows],
) )
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows]) counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
return [ return [
serialize_job_row( serialize_job_row(
r, r,
names=names, people=job_people_of(r,people),
hiring_manager_name=names.get(str(r.hiring_manager_id)),
applicant_count=counts.get(str(r.id),0), applicant_count=counts.get(str(r.id),0),
) )
for r in rows for r in rows
],total ],total
async def _job_row(self,row): async def fetch_job_profile(self,job_post_id,current_user=None,search=None,top=None,limit=None,skip=None):
names=await Users.names_by_ids( """Job profile page: the requisition row, its suggested candidates and the
Suggested / Top Match header stats one round trip.
Suggested = newest ats_results row per person for this job. Profile fields
and keywords come from that score's candidates row; a user- or form-identity
score has none, so it borrows the newest completed row for the same email."""
uid=JobPosts._as_uuid(job_post_id)
if uid is None:
raise HTTPException(status_code=422,detail="job_post_id must be a UUID")
# it's a system admin job profile page, so we don't need to restrict the ids
restrict=await self._restrict_ids_for_requisition_scope(current_user)
if restrict is not None and str(uid) not in {str(i) for i in restrict}:
raise HTTPException(status_code=404,detail="Job post not found")
job=await JobPosts.get_job_post_by_id(self.session,uid)
if not job or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
people=await Users.job_people(
self.session, self.session,
JobPosts.recruiter_ids_of(row)+[row.hiring_manager_id], JobPosts.recruiter_ids_of(job),
[job.hiring_manager_id],
) )
return serialize_job_row( counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[job.id])
row, job_payload=serialize_job_row(
names=names, job,
hiring_manager_name=names.get(str(row.hiring_manager_id)), people=people,
applicant_count=counts.get(str(job.id),0),
) )
rows=await AtsResults.latest_per_candidate_for_job(self.session,job.id,search,top,limit,skip)
total=await AtsResults.count_suggested_for_job(self.session,job.id,search)
emails=[user_email or form_email for row,_,user_email,_,form_email,candidate in rows if candidate is None]
by_email=await Candidates.latest_completed_for_job_by_emails(self.session,job.id,emails)
candidates=[]
for row,user_name,user_email,form_name,form_email,candidate in rows:
email=user_email or form_email
scored=candidate or by_email.get((email or "").strip().lower())
candidates.append(serialize_suggested_candidate(
row,
name=user_name or form_name,
email=email,
candidate=scored,
source=suggested_source(row.inbox_id,row.form_data_id,scored.source if scored else None),
optional_matched=optional_skill_hits(job.optional_skills,scored.matched_keywords if scored else []),
))
return {"job":job_payload,**suggested_summary(candidates),"total":total,"candidates":candidates}
async def _job_row(self,row):
people=await Users.job_people(
self.session,
JobPosts.recruiter_ids_of(row),
[row.hiring_manager_id],
)
return serialize_job_row(row,people=job_people_of(row,people))
async def _require_department(self,department_id): async def _require_department(self,department_id):
from department.models import Department from department.models import Department
department=await Department.get_by_id(self.session,department_id) department=await Department.get_by_id(self.session,department_id)

View File

@ -0,0 +1,220 @@
-- rbac_ingest.sql
-- One-shot ingestion of the RBAC tables: roles, permissions (bundles) and
-- permission_tags. Produces the same end state as manual migrations
-- 001, 004, 005, 007, 008, 019, 024, 026, 028 and 038 combined, so a fresh
-- database can be seeded in one pass.
--
-- Deliberately NOT under migrations/manual/ — run_manual_sql() only globs that
-- folder, so this file never auto-applies at startup. Run it by hand:
-- psql "$DATABASE_URL" -f migrations/seed/rbac_ingest.sql
--
-- Idempotent: every insert is ON CONFLICT DO NOTHING and bundle ids are only
-- appended to a role when missing, so re-running it is a no-op.
-- Users must log in again afterwards — the frontend caches /users/me permissions.
BEGIN;
-- =============================================================================
-- 1. permission_tags — every module x action (17 x 8 = 136), matches
-- users/permissions.py PermissionModule / PermissionAction
-- =============================================================================
INSERT INTO app.permission_tags
(tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
SELECT
m.module || '.' || a.action,
m.module,
a.action,
NULL,
NOW(),
NOW(),
true,
false
FROM unnest(ARRAY[
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews',
'assessments', 'offers', 'reports', 'analytics', 'job_board', 'settings',
'rbac_users', 'tasks', 'talent', 'requisitions', 'department'
]) WITH ORDINALITY AS m(module, m_ord)
CROSS JOIN unnest(ARRAY[
'view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure'
]) WITH ORDINALITY AS a(action, a_ord)
ORDER BY m.m_ord, a.a_ord
ON CONFLICT (tag_name) DO NOTHING;
-- =============================================================================
-- 2. roles — explicit ids: the app hardcodes candidate = 8 and
-- hiring_manager = 4 (inbox/models.py, users/models.py)
-- =============================================================================
INSERT INTO app.roles
(id, role_name, description, permissions, is_system, created_at, updated_at, is_active, is_deleted)
VALUES
(1, 'system_administrator', 'Full system access', '[]'::jsonb, true, NOW(), NOW(), true, false),
(2, 'hr_administrator', 'HR administration', '[]'::jsonb, true, NOW(), NOW(), false, true),
(3, 'recruiter', 'Recruiting staff', '[]'::jsonb, true, NOW(), NOW(), true, false),
(4, 'hiring_manager', 'Hiring manager for own requisitions', '[]'::jsonb, true, NOW(), NOW(), true, false),
(5, 'department_head', 'Head of a department', '[]'::jsonb, true, NOW(), NOW(), true, false),
(6, 'interviewer', 'Interview panel member', '[]'::jsonb, true, NOW(), NOW(), false, true),
(7, 'ceo', 'Chief executive', '[]'::jsonb, true, NOW(), NOW(), false, true),
(8, 'candidate', 'Applicant account', '[]'::jsonb, true, NOW(), NOW(), true, false)
ON CONFLICT DO NOTHING;
-- Explicit ids bypass the sequence; move it past them so UI-created roles don't collide.
SELECT setval(
pg_get_serial_sequence('app.roles', 'id'),
GREATEST((SELECT MAX(id) FROM app.roles), 1)
);
-- =============================================================================
-- 3. permissions — named bundles; each resolves to permission_tags ids by
-- module list and/or explicit tag names
-- =============================================================================
INSERT INTO app.permissions
(name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
SELECT
b.name,
b.description,
(
SELECT COALESCE(jsonb_agg(t.id ORDER BY t.id), '[]'::jsonb)
FROM app.permission_tags t
WHERE t.is_deleted = false
AND (t.module = ANY(b.modules) OR t.tag_name = ANY(b.tags))
),
true,
NOW(),
NOW(),
true,
false
FROM (VALUES
('all_access',
'Every permission in the original 13 modules',
ARRAY['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users']::text[],
ARRAY[]::text[]),
('analytics_dashboard',
'Dashboard KPI tiles, analytics charts, offers, and interview list',
ARRAY['dashboard', 'analytics', 'offers']::text[],
ARRAY['interviews.view']::text[]),
('tasks_management',
'Recruiting task list: view, create, complete and manage tasks',
ARRAY['tasks']::text[],
ARRAY[]::text[]),
('tasks_viewer',
'Recruiting task list: read-only access',
ARRAY[]::text[],
ARRAY['tasks.view', 'tasks.export']::text[]),
('talent_sourcing',
'LinkedIn talent sourcing: run Apify searches and view sourced profiles',
ARRAY['talent']::text[],
ARRAY[]::text[]),
('hiring_forms',
'Fill and amend candidate hiring forms (requisition, interview analysis, cultural fit)',
ARRAY[]::text[],
ARRAY['interviews.create', 'interviews.edit', 'interviews.delete']::text[]),
('requisitions_management',
'Employee requisition forms: view, create, edit and manage requisitions',
ARRAY['requisitions']::text[],
ARRAY[]::text[]),
('manager_candidates',
'Hiring manager: list candidates on own requisition jobs, view profiles, write notes',
ARRAY[]::text[],
ARRAY['candidates.view', 'candidates.create', 'candidates.edit']::text[]),
('requisitions_self',
'Own employee requisition forms: view, create, edit (not org-wide manage)',
ARRAY[]::text[],
ARRAY['requisitions.view', 'requisitions.create', 'requisitions.edit']::text[]),
('interviews_tab',
'Interviews and Calendar tabs: list, schedule, reschedule',
ARRAY[]::text[],
ARRAY['interviews.view', 'interviews.create', 'interviews.edit']::text[]),
('department_management',
'Departments: view, create, edit and manage departments',
ARRAY['department']::text[],
ARRAY[]::text[])
) AS b(name, description, modules, tags)
ON CONFLICT (name) DO NOTHING;
-- =============================================================================
-- 4. roles.permissions — attach bundle ids (append only what is missing).
-- requisitions_self and interviews_tab are for custom roles, so unattached.
-- =============================================================================
WITH role_bundles(role_name, bundle_name) AS (
VALUES
('system_administrator', 'all_access'),
('system_administrator', 'analytics_dashboard'),
('system_administrator', 'tasks_management'),
('system_administrator', 'talent_sourcing'),
('system_administrator', 'hiring_forms'),
('system_administrator', 'requisitions_management'),
('system_administrator', 'department_management'),
('hr_administrator', 'analytics_dashboard'),
('hr_administrator', 'tasks_management'),
('hr_administrator', 'talent_sourcing'),
('hr_administrator', 'hiring_forms'),
('hr_administrator', 'requisitions_management'),
('hr_administrator', 'department_management'),
('recruiter', 'analytics_dashboard'),
('recruiter', 'tasks_management'),
('recruiter', 'talent_sourcing'),
('recruiter', 'hiring_forms'),
('recruiter', 'requisitions_management'),
('hiring_manager', 'analytics_dashboard'),
('hiring_manager', 'tasks_viewer'),
('hiring_manager', 'talent_sourcing'),
('hiring_manager', 'hiring_forms'),
('hiring_manager', 'requisitions_management'),
('hiring_manager', 'manager_candidates'),
('department_head', 'analytics_dashboard'),
('department_head', 'tasks_viewer'),
('department_head', 'talent_sourcing'),
('department_head', 'hiring_forms'),
('department_head', 'requisitions_management'),
('ceo', 'analytics_dashboard'),
('ceo', 'tasks_viewer'),
('ceo', 'talent_sourcing'),
('ceo', 'hiring_forms'),
('ceo', 'requisitions_management')
),
wanted AS (
SELECT rb.role_name, p.id AS permission_id
FROM role_bundles rb
JOIN app.permissions p ON p.name = rb.bundle_name AND p.is_deleted = false
)
UPDATE app.roles r
SET permissions = (
SELECT COALESCE(jsonb_agg(ids.id ORDER BY ids.id), '[]'::jsonb)
FROM (
SELECT value::int AS id
FROM jsonb_array_elements_text(COALESCE(r.permissions, '[]'::jsonb))
UNION
SELECT w.permission_id FROM wanted w WHERE w.role_name = r.role_name
) ids
),
updated_at = NOW()
WHERE EXISTS (
SELECT 1 FROM wanted w
WHERE w.role_name = r.role_name
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(w.permission_id))
);
COMMIT;
-- =============================================================================
-- 5. Verify: role -> bundles -> resolved tag count
-- =============================================================================
SELECT
r.id,
r.role_name,
r.is_deleted,
string_agg(DISTINCT p.name, ', ' ORDER BY p.name) AS bundles,
COUNT(DISTINCT t.id) AS tag_count
FROM app.roles r
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(r.permissions, '[]'::jsonb)) rp(pid) ON true
LEFT JOIN app.permissions p ON p.id = rp.pid::int AND p.is_deleted = false
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(p.permission_tags, '[]'::jsonb)) pt(tid) ON true
LEFT JOIN app.permission_tags t ON t.id = pt.tid::int AND t.is_deleted = false
GROUP BY r.id, r.role_name, r.is_deleted
ORDER BY r.id;

View File

@ -208,17 +208,10 @@ async def system_admin_ids(session):
async def job_recruiter_ids(session, job): async def job_recruiter_ids(session, job):
"""Recruiters currently linked to the job post. """Recruiters currently linked to the job post.
Uses the live pointer (current_recruiter_id), the JSON list
(current_recruiter_ids), and open job_assignments rows with
assignment_role=primary_recruiter.
""" """
ids = set() ids = set()
if job is None: if job is None:
return ids return ids
uid = _as_uuid(getattr(job, "current_recruiter_id", None))
if uid is not None:
ids.add(uid)
for raw in getattr(job, "current_recruiter_ids", None) or []: for raw in getattr(job, "current_recruiter_ids", None) or []:
extra = _as_uuid(raw) extra = _as_uuid(raw)
if extra is not None: if extra is not None:

265
backend/role/plugins.py Normal file
View File

@ -0,0 +1,265 @@
"""RBAC declared in code: system roles, permission bundles, and the SQL that syncs them.
`PermissionTag` (users/permissions.py) is the tag vocabulary. `SYSTEM_ROLES` and
`RBAC_BUNDLES` below declare the rest. Adding a module means adding its enum values
and a bundle entry here no hand-written migrations/manual/*.sql.
`build_rbac_sql` turns the difference between this code and a database into
idempotent SQL. Admins curate roles in Access Control (a role's matrix replaces its
bundle list), so every code-declared grant a tag inside a bundle, a bundle on a
role is applied once per database and recorded in a ledger. A grant an admin later
removes stays removed; only grants the ledger has never seen are applied.
A database that already has roles but no ledger predates the sync. Its grants were
applied by the old manual migrations and may since have been curated, so the first
sync only records them. Bundles that do not exist yet are still created and filled.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import NamedTuple
from role.models import EnumRoles
from users.permissions import PermissionModule, PermissionTag
RBAC_LEDGER_TABLE = "rbac_sync_ledger"
class SystemRole(NamedTuple):
id: int # pinned: the app hardcodes candidate = 8 and hiring_manager = 4
name: EnumRoles
description: str
retired: bool = False # soft-deleted by 026; created retired on a fresh database
class Bundle(NamedTuple):
description: str
modules: tuple[PermissionModule, ...] = ()
tags: tuple[PermissionTag, ...] = ()
roles: tuple[EnumRoles, ...] = ()
SYSTEM_ROLES: tuple[SystemRole, ...] = (
SystemRole(1, EnumRoles.SYSTEM_ADMINISTRATOR, "Full system access"),
SystemRole(2, EnumRoles.HR_ADMINISTRATOR, "HR administration", retired=True),
SystemRole(3, EnumRoles.RECRUITER, "Recruiting staff"),
SystemRole(4, EnumRoles.HIRING_MANAGER, "Hiring manager for own requisitions"),
SystemRole(5, EnumRoles.DEPARTMENT_HEAD, "Head of a department"),
SystemRole(6, EnumRoles.INTERVIEWER, "Interview panel member", retired=True),
SystemRole(7, EnumRoles.CEO, "Chief executive", retired=True),
SystemRole(8, EnumRoles.CANDIDATE, "Applicant account"),
)
_R = EnumRoles
_T = PermissionTag
_M = PermissionModule
_STAFF = (
_R.SYSTEM_ADMINISTRATOR,
_R.HR_ADMINISTRATOR,
_R.RECRUITER,
_R.HIRING_MANAGER,
_R.DEPARTMENT_HEAD,
_R.CEO,
)
RBAC_BUNDLES: dict[str, Bundle] = {
"all_access": Bundle(
"Every permission in every module",
modules=tuple(PermissionModule),
roles=(_R.SYSTEM_ADMINISTRATOR,),
),
"analytics_dashboard": Bundle(
"Dashboard KPI tiles, analytics charts, offers, and interview list",
modules=(_M.DASHBOARD, _M.ANALYTICS, _M.OFFERS),
tags=(_T.INTERVIEWS_VIEW,),
roles=_STAFF,
),
"tasks_management": Bundle(
"Recruiting task list: view, create, complete and manage tasks",
modules=(_M.TASKS,),
roles=(_R.SYSTEM_ADMINISTRATOR, _R.HR_ADMINISTRATOR, _R.RECRUITER),
),
"tasks_viewer": Bundle(
"Recruiting task list: read-only access",
tags=(_T.TASKS_VIEW, _T.TASKS_EXPORT),
roles=(_R.HIRING_MANAGER, _R.DEPARTMENT_HEAD, _R.CEO),
),
"talent_sourcing": Bundle(
"LinkedIn talent sourcing: run Apify searches and view sourced profiles",
modules=(_M.TALENT,),
roles=_STAFF,
),
"hiring_forms": Bundle(
"Fill and amend candidate hiring forms (requisition, interview analysis, cultural fit)",
tags=(_T.INTERVIEWS_CREATE, _T.INTERVIEWS_EDIT, _T.INTERVIEWS_DELETE),
roles=_STAFF,
),
"requisitions_management": Bundle(
"Employee requisition forms: view, create, edit and manage requisitions",
modules=(_M.REQUISITIONS,),
roles=_STAFF,
),
"manager_candidates": Bundle(
"Hiring manager: list candidates on own requisition jobs, view profiles, write notes",
tags=(_T.CANDIDATES_VIEW, _T.CANDIDATES_CREATE, _T.CANDIDATES_EDIT),
roles=(_R.HIRING_MANAGER,),
),
# Unattached: admins tick these on custom roles in Access Control.
"requisitions_self": Bundle(
"Own employee requisition forms: view, create, edit (not org-wide manage)",
tags=(_T.REQUISITIONS_VIEW, _T.REQUISITIONS_CREATE, _T.REQUISITIONS_EDIT),
),
"interviews_tab": Bundle(
"Interviews and Calendar tabs: list, schedule, reschedule",
tags=(_T.INTERVIEWS_VIEW, _T.INTERVIEWS_CREATE, _T.INTERVIEWS_EDIT),
),
"department_management": Bundle(
"Departments: view, create, edit and manage departments",
modules=(_M.DEPARTMENT,),
roles=(_R.SYSTEM_ADMINISTRATOR, _R.HR_ADMINISTRATOR),
),
}
@dataclass(frozen=True)
class RbacState:
"""What the database already holds, read before planning."""
tags: frozenset[str]
roles: frozenset[str]
bundles: frozenset[str]
ledger: frozenset[tuple[str, str]] # (kind, key)
def bundle_tag_names(bundle: Bundle) -> list[str]:
"""The bundle's tags in vocabulary order: whole modules plus explicit tags."""
modules = {m.value for m in bundle.modules}
explicit = set(bundle.tags)
return [t.value for t in PermissionTag if t.value.split(".")[0] in modules or t in explicit]
def _lit(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _in(values: list[str]) -> str:
return "(" + ", ".join(_lit(v) for v in values) + ")"
def _rows(rows: list[str]) -> str:
return ",\n ".join(rows)
def build_rbac_sql(state: RbacState, *, schema: str | None) -> list[str]:
"""Idempotent statements that bring the database up to the code. Empty when in sync."""
def table(name: str) -> str:
return f'"{schema}".{name}' if schema else name
tags_t = table("permission_tags")
roles_t = table("roles")
perms_t = table("permissions")
ledger_t = table(RBAC_LEDGER_TABLE)
adopt = not state.ledger and bool(state.roles)
sql: list[str] = []
recorded: list[tuple[str, str]] = []
missing_tags = [t.value for t in PermissionTag if t.value not in state.tags]
if missing_tags:
values = _rows([
f"({_lit(tag)}, {_lit(tag.split('.')[0])}, {_lit(tag.split('.')[1])}, "
"NOW(), NOW(), true, false)"
for tag in missing_tags
])
sql.append(f"""\
INSERT INTO {tags_t}
(tag_name, module, action, created_at, updated_at, is_active, is_deleted)
VALUES
{values}
ON CONFLICT (tag_name) DO NOTHING;""")
missing_roles = [r for r in SYSTEM_ROLES if r.name.value not in state.roles]
if missing_roles:
values = _rows([
f"({r.id}, {_lit(r.name.value)}, {_lit(r.description)}, '[]'::jsonb, true, "
f"NOW(), NOW(), {str(not r.retired).lower()}, {str(r.retired).lower()})"
for r in missing_roles
])
sql.append(f"""\
INSERT INTO {roles_t}
(id, role_name, description, permissions, is_system,
created_at, updated_at, is_active, is_deleted)
VALUES
{values}
ON CONFLICT DO NOTHING;""")
# Pinned ids bypass the sequence; move it past them so new roles don't collide.
sql.append(
f"SELECT setval(pg_get_serial_sequence('{roles_t}', 'id'), "
f"GREATEST((SELECT MAX(id) FROM {roles_t}), 1));"
)
for name, bundle in RBAC_BUNDLES.items():
created = name not in state.bundles
if created:
sql.append(f"""\
INSERT INTO {perms_t}
(name, description, permission_tags, is_system,
created_at, updated_at, is_active, is_deleted)
VALUES
({_lit(name)}, {_lit(bundle.description)}, '[]'::jsonb, true, NOW(), NOW(), true, false)
ON CONFLICT (name) DO NOTHING;""")
apply = created or not adopt
tags = [
t for t in bundle_tag_names(bundle)
if ("bundle_tag", f"{name}:{t}") not in state.ledger
]
if tags:
recorded += [("bundle_tag", f"{name}:{t}") for t in tags]
if apply:
sql.append(f"""\
UPDATE {perms_t} p
SET permission_tags = COALESCE(p.permission_tags, '[]'::jsonb) || (
SELECT COALESCE(jsonb_agg(t.id ORDER BY t.id), '[]'::jsonb)
FROM {tags_t} t
WHERE t.tag_name IN {_in(tags)}
AND NOT (COALESCE(p.permission_tags, '[]'::jsonb) @> jsonb_build_array(t.id))
),
updated_at = NOW()
WHERE p.name = {_lit(name)};""")
roles = [
r.value for r in bundle.roles
if ("role_bundle", f"{r.value}:{name}") not in state.ledger
]
if roles:
recorded += [("role_bundle", f"{r}:{name}") for r in roles]
if apply:
sql.append(f"""\
UPDATE {roles_t} r
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
updated_at = NOW()
FROM {perms_t} p
WHERE p.name = {_lit(name)}
AND r.role_name IN {_in(roles)}
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));""")
if not sql and not recorded:
return []
ledger_sql = [f"""\
CREATE TABLE IF NOT EXISTS {ledger_t} (
kind text NOT NULL,
key text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (kind, key)
);"""]
if recorded:
values = _rows([f"({_lit(kind)}, {_lit(key)})" for kind, key in recorded])
ledger_sql.append(f"""\
INSERT INTO {ledger_t} (kind, key)
VALUES
{values}
ON CONFLICT DO NOTHING;""")
return ledger_sql + sql

View File

@ -0,0 +1,75 @@
"""Unit tests for the job-profile helpers in job/job_post/plugins.py — pure functions only."""
from __future__ import annotations
from job.job_post import plugins
# ---------------------------------------------------------------- suggested_source
def test_inbox_score_is_email_sourced_even_with_a_candidates_row():
assert plugins.suggested_source(42, None, "upload") == "inbox"
def test_form_score_is_form_sourced():
assert plugins.suggested_source(None, "f-1", None) == "form"
def test_upload_score_uses_the_candidates_row_source():
assert plugins.suggested_source(None, None, "bank") == "bank"
assert plugins.suggested_source(None, None, None) == "upload"
assert plugins.suggested_source(None, None, " ") == "upload"
# ---------------------------------------------------------------- optional_skill_hits
def test_exact_match_ignores_case_and_punctuation():
assert plugins.optional_skill_hits(["FMCG Background", "Arabic"], ["fmcg-background"]) == ["FMCG Background"]
def test_keyword_inside_skill_counts_on_word_boundaries():
assert plugins.optional_skill_hits(["CRM (Salesforce)"], ["Salesforce"]) == ["CRM (Salesforce)"]
def test_skill_inside_keyword_counts():
assert plugins.optional_skill_hits(["Arabic"], ["Arabic language"]) == ["Arabic"]
def test_partial_words_do_not_count():
assert plugins.optional_skill_hits(["Java"], ["JavaScript"]) == []
def test_hits_keep_job_order_and_skip_duplicates():
hits = plugins.optional_skill_hits(["Travel", "Arabic", "Arabic"], ["arabic", "travel"])
assert hits == ["Travel", "Arabic"]
def test_no_optional_skills_or_keywords_is_empty():
assert plugins.optional_skill_hits([], ["Python"]) == []
assert plugins.optional_skill_hits(["Python"], None) == []
# ---------------------------------------------------------------- suggested_summary
def test_summary_counts_bands_and_top_match():
candidates = [
{"band": "Strong Match", "match_score": 91},
{"band": "Strong Match", "match_score": 85},
{"band": "Potential Match", "match_score": 70},
{"band": "Weak Match", "match_score": 40},
{"band": None, "match_score": None},
]
summary = plugins.suggested_summary(candidates)
assert summary["suggested"] == 5
assert summary["top_match"] == 2
assert summary["top_score"] == 91
assert summary["bands"] == {"Strong Match": 2, "Potential Match": 1, "Weak Match": 1}
def test_empty_summary():
assert plugins.suggested_summary([]) == {
"suggested": 0,
"top_match": 0,
"top_score": None,
"bands": {"Strong Match": 0, "Potential Match": 0, "Weak Match": 0},
}

View File

@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlmodel import Field, Relationship, SQLModel, select from sqlmodel import Field, Relationship, SQLModel, select
from role.models import Roles from role.models import EnumRoles, Roles
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module
@ -154,8 +154,12 @@ class Users(SQLModel, table=True):
return [row[0] for row in result.all()] return [row[0] for row in result.all()]
@classmethod @classmethod
async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]: async def names_by_ids(cls, session: AsyncSession, user_ids,search=None,top=None,limit=None) -> dict[str, str]:
"""Resolve {user_id: name} in a single query. """Resolve {user_id: name} in a single query, whatever role those ids hold.
Shared by departments, offers, history, notifications and the job payloads,
so it stays role-agnostic: a read-time role filter cannot fix bad data, it
only makes names disappear. Role is enforced on write by require_role.
COLUMN select, not the Users entity: `select(cls)` would pull the five COLUMN select, not the Users entity: `select(cls)` would pull the five
selectin relations (role, job_posts, inbox, feedback, notes) for a selectin relations (role, job_posts, inbox, feedback, notes) for a
@ -164,11 +168,49 @@ class Users(SQLModel, table=True):
uids = {u for u in (user_ids or []) if u} uids = {u for u in (user_ids or []) if u}
if not uids: if not uids:
return {} return {}
result = await session.execute( statement =(
select(cls.id, cls.name).where(cls.id.in_(uids)) select(cls.id, cls.name).where(cls.id.in_(uids))
) )
if search:
statement = statement.where(cls.name.ilike(f"%{search}%"))
if top:
statement = statement.limit(top)
if limit:
statement = statement.limit(limit)
result = await session.execute(statement)
return {str(uid): name for uid, name in result.all()} return {str(uid): name for uid, name in result.all()}
@classmethod
async def job_people(cls, session: AsyncSession, recruiter_ids, manager_ids=None) -> dict[str, dict[str, str]]:
"""{"recruiters": {id: name}, "hiring_manager": {id: name}} — the two job
ownership roles resolved apart, one query each.
They are different roles on a job post, so they never share a container.
The manager side is role-checked (hiring_manager, not deleted) because it
is a single stable owner; recruiters are validated on write. Both sides
take a list, so one call serves a whole page of jobs.
"""
data: dict[str, dict[str, str]] = {"recruiters": {}, "hiring_manager": {}}
rids = {u for u in (recruiter_ids or []) if u}
if rids:
result = await session.execute(select(cls.id, cls.name).where(cls.id.in_(rids)))
data["recruiters"] = {str(uid): name for uid, name in result.all()}
mids = {u for u in (manager_ids or []) if u}
if mids:
result = await session.execute(
select(cls.id, cls.name)
.join(Roles, Roles.id == cls.role_id)
.where(
cls.id.in_(mids),
Roles.role_name == EnumRoles.HIRING_MANAGER.value,
cls.is_deleted == False, # noqa: E712
)
)
data["hiring_manager"] = {str(uid): name for uid, name in result.all()}
return data
@classmethod @classmethod
async def get_by_ids(cls, session: AsyncSession, ids): async def get_by_ids(cls, session: AsyncSession, ids):
"""Users with role selectin-loaded. UUID keys so callers can map by row.assignee_id.""" """Users with role selectin-loaded. UUID keys so callers can map by row.assignee_id."""

View File

@ -0,0 +1,353 @@
/**
* Job profile page test /job/:jobId rendered into jsdom against a mocked
* GET /jobs/profile/fetch.
*
* node job-profile.test.mjs
*
* Pins the design contract: employment type in the hero line, Suggested and
* Top Match stats from the one profile request, Optional Skills highlighted
* below Required Skills, and suggested-candidate cards ranked best worst with
* matched optional skills highlighted on each card.
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import esbuild from 'esbuild'
import { JSDOM } from 'jsdom'
// ---------------------------------------------------------------- environment
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
url: 'http://localhost:5173/',
pretendToBeVisual: true,
})
globalThis.window = dom.window
globalThis.document = dom.window.document
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
globalThis.HTMLElement = dom.window.HTMLElement
globalThis.Element = dom.window.Element
globalThis.Node = dom.window.Node
globalThis.getComputedStyle = dom.window.getComputedStyle
globalThis.localStorage = dom.window.localStorage
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0)
globalThis.cancelAnimationFrame = clearTimeout
globalThis.IS_REACT_ACT_ENVIRONMENT = true
class RO { observe() {} unobserve() {} disconnect() {} }
class MO { observe() {} disconnect() {} takeRecords() { return [] } }
globalThis.ResizeObserver = RO
globalThis.MutationObserver = MO
dom.window.ResizeObserver = RO
dom.window.MutationObserver = MO
dom.window.matchMedia = () => ({
matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {},
})
dom.window.HTMLCanvasElement.prototype.getContext = () =>
new Proxy({}, { get: (_, k) => (k === 'canvas' ? {} : () => {}) })
const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent',
'requisitions']
const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure']
const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`))
dom.window.localStorage.setItem('tf-auth', JSON.stringify({
access_token: 'test', refresh_token: 'test', expires_in: 1800,
expires_at: Date.now() + 1800_000,
data: { id: 1, name: 'Test User', email: 't@example.com', role_name: 'system_administrator', permissions },
}))
// ---------------------------------------------------------------- fixtures
const JOB_ID = '11111111-2222-3333-4444-555555555555'
function jobRow(overrides = {}) {
return {
id: JOB_ID,
title: 'Regional Sales Executive',
department: 'GEO',
location: 'Multi-region',
employment_type: 'Permanent',
vacancies: 3,
platform: 'linkedin',
requisition_status: 'open',
status: 'draft',
experience_min: 3,
experience_max: 5,
requirements: ['B2B Sales', 'Distributor Management'],
optional_skills: ['Arabic', 'FMCG Background', 'Regional Travel'],
description: 'Own the sales pipeline across the GEO region.',
current_recruiter_ids: [],
recruiter_names: ['Nida Khan'],
hiring_manager_name: 'Amara Osei',
applicant_count: 42,
created_by_name: 'Nida Khan',
created_at: '2026-08-12T09:00:00Z',
...overrides,
}
}
function candidate(overrides) {
return {
id: overrides.id,
user_id: null, candidate_id: null, form_data_id: null, inbox_id: null,
email: null, current_title: null, current_company: null, years_experience: null,
matched_keywords: [], missing_keywords: [], optional_matched: [],
summary: null, source: 'upload', scored_candidate_id: null,
created_at: '2026-09-01T10:00:00Z',
...overrides,
}
}
const CANDIDATES = [
candidate({
id: 'a1', name: 'Sana Iqbal', match_score: 91, band: 'Strong Match', source: 'inbox',
current_title: 'Regional Sales Manager', current_company: 'Unilever', years_experience: 6,
matched_keywords: ['B2B Sales', 'Distributor Management'], optional_matched: ['Arabic'],
summary: 'Six years leading distributor relationships across GCC.', created_at: '2026-09-01T10:00:00Z',
}),
candidate({
id: 'a2', name: 'Ayesha Noor', match_score: 78, band: 'Potential Match',
matched_keywords: ['B2B Sales'], missing_keywords: ['Distributor Management'],
created_at: '2026-09-05T10:00:00Z',
}),
candidate({
id: 'a3', name: 'Bilal Ahmed', match_score: 40, band: 'Weak Match', source: 'form',
created_at: '2026-09-03T10:00:00Z',
}),
]
let profilePayload = null
const REQUESTS = []
/** Stand-in for the API's search offset limit, so paging is exercised
against a server that really returns one page and a total. */
function serveProfile(url) {
const params = new URL(url, 'http://localhost').searchParams
const term = (params.get('search') || '').toLowerCase()
const top = Number(params.get('top') || 0)
const skip = Number(params.get('skip') || 0)
const matching = profilePayload.candidates.filter((c) => !term || String(c.name).toLowerCase().includes(term))
const page = top ? matching.slice(skip, skip + top) : matching.slice(skip)
return { data: { ...profilePayload, total: matching.length, candidates: page }, total: matching.length, status_code: 200 }
}
globalThis.fetch = async (input) => {
const url = String(input?.url ?? input)
REQUESTS.push(url)
const body = url.includes('/jobs/profile/fetch')
? serveProfile(url)
: { data: [], status_code: 200 }
return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(body) }
}
// ---------------------------------------------------------------- bundle
const outDir = mkdtempSync(join(tmpdir(), 'tf-jobprofile-'))
const outFile = join(outDir, 'entry.mjs')
await esbuild.build({
entryPoints: ['src/__smoke__/entry.jsx'],
outfile: outFile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
jsx: 'automatic',
loader: { '.js': 'jsx', '.jsx': 'jsx' },
logLevel: 'error',
define: {
'process.env.NODE_ENV': '"development"',
'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }),
},
})
// ---------------------------------------------------------------- run
const errors = []
const origError = console.error
console.error = (...args) => {
const msg = args.map((a) => (a instanceof Error ? a.stack : String(a))).join(' ')
if (msg.includes('React Router Future Flag')) return
errors.push(msg)
}
let failed = 0
function check(name, ok, detail = '') {
if (ok) console.log(`ok ${name}`)
else {
failed++
console.log(`FAIL ${name}${detail ? `\n ${detail}` : ''}`)
}
}
try {
const mod = await import(pathToFileURL(outFile).href)
mod.boot()
// ------------------------------------------------ full profile
profilePayload = {
job: jobRow(),
suggested: 3,
top_match: 1,
top_score: 91,
bands: { 'Strong Match': 1, 'Potential Match': 1, 'Weak Match': 1 },
candidates: CANDIDATES,
}
let container = dom.window.document.createElement('div')
dom.window.document.body.appendChild(container)
let m = await mod.mountRoute(`/job/${JOB_ID}`, container)
await m.settle(60)
const profileCalls = REQUESTS.filter((u) => u.includes('/jobs/profile/fetch'))
check('one profile request feeds the page', profileCalls.length === 1, `calls=${profileCalls.length}`)
check('profile request carries the job id', profileCalls[0]?.includes(`job_post_id=${JOB_ID}`), profileCalls[0])
const role = m.find('.job-hero .ph-role')?.textContent || ''
check('hero line shows department, location and employment type', role === 'GEO · Multi-region · Permanent', `role="${role}"`)
const stats = [...container.querySelectorAll('.hero-stat')].map((el) => ({
v: el.querySelector('.v')?.textContent, l: el.querySelector('.l')?.textContent,
}))
check('Suggested stat reads 3', stats.some((s) => s.l === 'Suggested' && s.v === '3'), JSON.stringify(stats))
check('Top Match stat counts the Strong Match band', stats.some((s) => s.l === 'Top Match' && s.v === '1'), JSON.stringify(stats))
check('hero tags show vacancies and applicants', m.text().includes('3 Vacancies') && m.text().includes('42 Applicants'))
const html = m.html()
const reqAt = html.indexOf('Required Skills')
const optAt = html.indexOf('Optional Skills')
check('Optional Skills section sits below Required Skills', reqAt > -1 && optAt > reqAt, `req=${reqAt} opt=${optAt}`)
const optionalTags = [...container.querySelectorAll('.tag.tag-optional')].map((el) => el.textContent.trim())
check('every optional skill is a highlighted tag', optionalTags.join('|') === 'Arabic|FMCG Background|Regional Travel', optionalTags.join('|'))
check('created line joins date and author', m.text().includes('by Nida Khan'))
// ------------------------------------------------ suggested tab
await m.click(m.findByText('[role="tab"]', 'Suggested Candidates'))
let cards = [...container.querySelectorAll('.cand-card')]
check('one card per suggested candidate', cards.length === 3, `cards=${cards.length}`)
const names = () => [...container.querySelectorAll('.cand-card .cand-name')].map((el) => el.textContent.trim())
check('default sort is best → worst', names().join('|') === 'Sana Iqbal|Ayesha Noor|Bilal Ahmed', names().join('|'))
const ranks = [...container.querySelectorAll('.cand-rank')].map((el) => el.textContent)
check('cards are ranked 1..n', ranks.join(',') === '1,2,3', ranks.join(','))
check('sub line counts and marks the sort', m.text().includes('3 candidates suggested') && m.text().includes('Sorted: Best → Worst'))
const first = container.querySelector('.cand-card')
const optChips = [...first.querySelectorAll('.cand-chip.opt')].map((el) => el.textContent.trim())
check('matched optional skill is highlighted on its card', optChips.join('|') === 'Arabic', optChips.join('|'))
check('matched and missing chips render', first.querySelectorAll('.cand-chip.ok').length === 2
&& container.querySelectorAll('.cand-card')[1].querySelectorAll('.cand-chip.miss').length === 1)
check('source label maps inbox → Email', first.textContent.includes('Email'))
check('foot shows years and company', first.textContent.includes('6 yrs · Unilever'))
const sortSelect = container.querySelector('#suggested-sort')
await m.selectOption(sortSelect, 'name')
check('A → Z sort orders by name and drops ranks',
names().join('|') === 'Ayesha Noor|Bilal Ahmed|Sana Iqbal' && !container.querySelector('.cand-rank'), names().join('|'))
await m.selectOption(sortSelect, 'recent')
check('Most Recent sort orders by score time', names().join('|') === 'Ayesha Noor|Bilal Ahmed|Sana Iqbal', names().join('|'))
await m.selectOption(container.querySelector('select[aria-label="Match band"]'), 'Weak Match')
check('band filter narrows to that band', names().join('|') === 'Bilal Ahmed', names().join('|'))
// ------------------------------------------------ search / top go to the API
const profileCallsNow = () => REQUESTS.filter((u) => u.includes('/jobs/profile/fetch'))
const lastParams = () => new URL(profileCallsNow().at(-1), 'http://localhost').searchParams
check('first request sends the default size as top=50', new URL(profileCallsNow()[0], 'http://localhost').searchParams.get('top') === '50')
check('first request sends no search', !new URL(profileCallsNow()[0], 'http://localhost').searchParams.has('search'))
check('first request sends no limit', !new URL(profileCallsNow()[0], 'http://localhost').searchParams.has('limit'))
const before = profileCallsNow().length
await m.type(container.querySelector('.toolbar-search input'), 'sana')
check('typing a search re-requests the profile', profileCallsNow().length > before)
check('search is sent as search=', lastParams().get('search') === 'sana', lastParams().get('search'))
check('search keeps the size', lastParams().get('top') === '50')
await m.selectOption(container.querySelector('.page-size-select'), '10')
check('Show changes top', lastParams().get('top') === '10', lastParams().get('top'))
check('Show keeps the search', lastParams().get('search') === 'sana')
await m.type(container.querySelector('.toolbar-search input'), ' ')
check('blank search is not sent', !lastParams().has('search'))
await m.unmount()
container.remove()
// ------------------------------------------------ page arrows
// 120 candidates: two full pages of 50 and a short third page of 20.
const PAGED_ID = '77777777-2222-3333-4444-555555555555'
profilePayload = {
job: jobRow({ id: PAGED_ID }),
suggested: 50, top_match: 0, top_score: 100,
bands: { 'Strong Match': 0, 'Potential Match': 0, 'Weak Match': 0 },
candidates: Array.from({ length: 120 }, (_, i) => candidate({
id: `p${i}`, name: `Candidate ${String(i).padStart(3, '0')}`, match_score: 100 - i, band: 'Weak Match',
})),
}
container = dom.window.document.createElement('div')
dom.window.document.body.appendChild(container)
m = await mod.mountRoute(`/job/${PAGED_ID}?tab=suggested`, container)
await m.settle(60)
const pageCards = () => container.querySelectorAll('.cand-card').length
const pageNames = () => [...container.querySelectorAll('.cand-name')].map((el) => el.textContent.trim())
const pageRanks = () => [...container.querySelectorAll('.cand-rank')].map((el) => el.textContent)
const lastCall = () => new URL(REQUESTS.filter((u) => u.includes('/jobs/profile/fetch')).at(-1), 'http://localhost').searchParams
check('pagination control renders under the grid', container.querySelectorAll('.pagination').length === 1)
check('first page holds 50 of 120', pageCards() === 50, `cards=${pageCards()}`)
check('page info reads 150 of 120', m.text().includes('150') && m.text().includes('of 120'))
check('first request asks for skip-less page 1', !lastCall().get('skip') || lastCall().get('skip') === '0')
check('Suggested stat shows the whole total, not the page', container.querySelector('.hero-stat .v')?.textContent === '120')
check('previous arrow is disabled on page 1', container.querySelector('.page-btn[aria-label="Previous page"]')?.disabled === true)
await m.click(container.querySelector('.page-btn[aria-label="Next page"]'))
check('next arrow requests skip=50', lastCall().get('skip') === '50', lastCall().get('skip'))
check('next arrow keeps top=50', lastCall().get('top') === '50')
check('page 2 starts where page 1 stopped', pageNames()[0] === 'Candidate 050', pageNames()[0])
check('ranks continue across pages', pageRanks()[0] === '51' && pageRanks().at(-1) === '100', pageRanks()[0])
await m.click(container.querySelector('.page-btn[aria-label="Last page"]'))
check('last page requests skip=100', lastCall().get('skip') === '100')
check('last page holds the remaining 20', pageCards() === 20, `cards=${pageCards()}`)
check('page info reads 101120 of 120', m.text().includes('101120'))
check('next arrow is disabled on the last page', container.querySelector('.page-btn[aria-label="Next page"]')?.disabled === true)
await m.click(container.querySelector('.page-btn[aria-label="Previous page"]'))
// Page 2 was fetched already, so React Query may serve it from cache without a
// new request — assert what is on screen, not the last URL.
check('previous arrow steps back to page 2', pageNames()[0] === 'Candidate 050' && m.text().includes('51100'), pageNames()[0])
await m.type(container.querySelector('.toolbar-search input'), 'Candidate 11')
check('a new search goes back to page 1', !lastCall().get('skip') || lastCall().get('skip') === '0', lastCall().get('skip'))
check('search narrows the total', m.text().includes('of 10'), m.text().match(/of \d+/)?.[0])
await m.unmount()
container.remove()
// ------------------------------------------------ sparse job
// A different id: the query client is shared across mounts, so the first
// job's profile is still cached under its own key.
const SPARSE_ID = '99999999-2222-3333-4444-555555555555'
profilePayload = {
job: jobRow({ id: SPARSE_ID, employment_type: null, optional_skills: [] }),
suggested: 0, top_match: 0, top_score: null,
bands: { 'Strong Match': 0, 'Potential Match': 0, 'Weak Match': 0 },
candidates: [],
}
container = dom.window.document.createElement('div')
dom.window.document.body.appendChild(container)
m = await mod.mountRoute(`/job/${SPARSE_ID}?tab=suggested`, container)
await m.settle(60)
const sparseRole = m.find('.job-hero .ph-role')?.textContent || ''
check('no employment type → hero line omits it', sparseRole === 'GEO · Multi-region', `role="${sparseRole}"`)
check('?tab=suggested opens that tab, with an empty state', m.text().includes('No suggested candidates yet'))
await m.click(m.findByText('[role="tab"]', 'Details'))
check('no optional skills → no Optional Skills section', !m.text().includes('Optional Skills'))
await m.unmount()
container.remove()
check('no console errors', errors.length === 0, errors[0]?.split('\n').slice(0, 3).join(' | '))
} finally {
console.error = origError
rmSync(outDir, { recursive: true, force: true })
}
console.log(failed ? `\n${failed} job profile check(s) FAILED` : '\nAll job profile checks passed')
process.exit(failed ? 1 : 0)

View File

@ -18,7 +18,8 @@
"test:cvbank": "node cvbank.test.mjs", "test:cvbank": "node cvbank.test.mjs",
"test:browse": "node candidate-browse.test.mjs", "test:browse": "node candidate-browse.test.mjs",
"test:mobile": "node mobile.test.mjs", "test:mobile": "node mobile.test.mjs",
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs && node candidate-browse.test.mjs" "test:jobprofile": "node job-profile.test.mjs",
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs && node job-profile.test.mjs && node candidate-browse.test.mjs"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",

View File

@ -55,6 +55,7 @@ function LegacyCandidateRedirect() {
const to = `/candidate/${encodeURIComponent(userId)}` const to = `/candidate/${encodeURIComponent(userId)}`
return <Navigate to={tab ? `${to}?tab=${encodeURIComponent(tab)}` : to} replace /> return <Navigate to={tab ? `${to}?tab=${encodeURIComponent(tab)}` : to} replace />
} }
const JobProfile = lazy(() => import('./screens/JobProfile'))
export default function App() { export default function App() {
return ( return (
@ -104,6 +105,14 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/job/:jobId"
element={
<RequireAuth permission="jobs.view">
<JobProfile />
</RequireAuth>
}
/>
<Route <Route
path="/candidates/:userId" path="/candidates/:userId"
element={ element={

View File

@ -50,6 +50,7 @@ import Notifications from '../screens/Notifications'
import Rbac from '../screens/Rbac' import Rbac from '../screens/Rbac'
import Settings from '../screens/Settings' import Settings from '../screens/Settings'
import Help from '../screens/Help' import Help from '../screens/Help'
import JobProfile from '../screens/JobProfile'
const SCREENS = { const SCREENS = {
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates, dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
@ -68,6 +69,11 @@ const PAGES = {
'/auth/confirm-email': ConfirmEmail, '/auth/confirm-email': ConfirmEmail,
} }
// Detail pages live outside the ROUTES table (parameterized path), as in App.jsx.
const DETAIL_PAGES = [
{ pattern: '/job/:jobId', prefix: '/job/', Screen: JobProfile },
]
export const ALL_ROUTES = [ export const ALL_ROUTES = [
...Object.keys(PAGES), ...Object.keys(PAGES),
...TABLE.map((r) => `/${r.path}`), ...TABLE.map((r) => `/${r.path}`),
@ -112,9 +118,21 @@ export async function mountRoute(path, container) {
}) })
await settle() await settle()
} }
// React tracks an input's value through the native setter; assigning
// el.value directly is invisible to onChange, so go through the prototype.
const type = async (el, value) => {
if (!el) throw new Error('type: element not found')
const win = el.ownerDocument.defaultView
await act(async () => {
Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, 'value').set.call(el, value)
el.dispatchEvent(new win.Event('input', { bubbles: true }))
})
await settle()
}
return { return {
settle, settle,
type,
click, click,
selectOption, selectOption,
html: () => container.innerHTML, html: () => container.innerHTML,
@ -129,15 +147,17 @@ export async function mountRoute(path, container) {
function routeTree(path) { function routeTree(path) {
const h = React.createElement const h = React.createElement
const isAuth = path.startsWith('/auth/') const isAuth = path.startsWith('/auth/')
const def = TABLE.find((r) => `/${r.path}` === path) const detail = DETAIL_PAGES.find((d) => path.startsWith(d.prefix))
const Screen = isAuth ? PAGES[path] : SCREENS[def.path] const def = TABLE.find((r) => `/${r.path}` === path.split('?')[0])
const Screen = isAuth ? PAGES[path] : detail ? detail.Screen : SCREENS[def.path]
const routePath = detail ? detail.pattern : path.split('?')[0]
const inner = isAuth const inner = isAuth
? h(Route, { path, element: h(Screen) }) ? h(Route, { path, element: h(Screen) })
: h( : h(
Route, Route,
{ element: h(RequireAuth, null, h(AppLayout)) }, { element: h(RequireAuth, null, h(AppLayout)) },
h(Route, { path, element: h(Screen) }), h(Route, { path: routePath, element: h(Screen) }),
) )
return h( return h(

View File

@ -64,7 +64,24 @@ function experienceLabel(min, max) {
return `${min ?? max}+ years` return `${min ?? max}+ years`
} }
/* Job ownership arrives in one of two shapes. serialize_job_row (GET /jobs/fetch,
GET /jobs/profile/fetch) sends the two roles as separate {id: name} objects:
"recruiters": {"ed9e…": "Nida Khan"}, "hiring_manager": {"77aa…": "Amara Osei"}
serialize_job_post the inbox / matching / picker payload still sends the
older flat keys (current_recruiter_ids, recruiter_names, recruiters as an
ARRAY of {id, name}, hiring_manager_id / hiring_manager_name). Both are read
here so one mapper serves every caller; the Array check is what tells the two
`recruiters` shapes apart. */
function recruiterMap(row) {
const value = row?.recruiters
return value && typeof value === 'object' && !Array.isArray(value) ? value : null
}
function recruiterIdsFrom(row) { function recruiterIdsFrom(row) {
const map = recruiterMap(row)
if (map) return Object.keys(map).map(String)
const ids = Array.isArray(row?.current_recruiter_ids) const ids = Array.isArray(row?.current_recruiter_ids)
? row.current_recruiter_ids.filter(Boolean).map(String) ? row.current_recruiter_ids.filter(Boolean).map(String)
: [] : []
@ -73,6 +90,8 @@ function recruiterIdsFrom(row) {
} }
function recruiterNamesFrom(row) { function recruiterNamesFrom(row) {
const map = recruiterMap(row)
if (map) return Object.values(map).filter(Boolean)
if (Array.isArray(row?.recruiter_names) && row.recruiter_names.length) { if (Array.isArray(row?.recruiter_names) && row.recruiter_names.length) {
return row.recruiter_names.filter(Boolean) return row.recruiter_names.filter(Boolean)
} }
@ -82,10 +101,24 @@ function recruiterNamesFrom(row) {
return row?.recruiter_name ? [row.recruiter_name] : [] return row?.recruiter_name ? [row.recruiter_name] : []
} }
/** {id, name} of the hiring manager, from either payload shape. */
function hiringManagerFrom(row) {
const value = row?.hiring_manager
if (value && typeof value === 'object') {
const [id, name] = Object.entries(value)[0] ?? []
if (id) return { id: String(id), name: name ?? null }
}
return {
id: row?.hiring_manager_id ? String(row.hiring_manager_id) : null,
name: row?.hiring_manager_name ?? null,
}
}
/** API row -> what the Jobs table and detail modal render. */ /** API row -> what the Jobs table and detail modal render. */
export function toJobView(row) { export function toJobView(row) {
const recruiterIds = recruiterIdsFrom(row) const recruiterIds = recruiterIdsFrom(row)
const recruiterNames = recruiterNamesFrom(row) const recruiterNames = recruiterNamesFrom(row)
const manager = hiringManagerFrom(row)
return { return {
id: row.id, id: row.id,
title: row.title, title: row.title,
@ -101,8 +134,8 @@ export function toJobView(row) {
recruiterId: recruiterIds[0] || null, recruiterId: recruiterIds[0] || null,
recruiterIds, recruiterIds,
recruiterNames, recruiterNames,
hiringManager: row.hiring_manager_name, hiringManager: manager.name,
hiringManagerId: row.hiring_manager_id, hiringManagerId: manager.id,
createdByName: row.created_by_name, createdByName: row.created_by_name,
applicantCount: row.applicant_count ?? 0, applicantCount: row.applicant_count ?? 0,
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working. // A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.
@ -178,6 +211,46 @@ export function setStatus(jobPostId, status) {
}) })
} }
/**
* Job profile page GET /jobs/profile/fetch. One round trip: the requisition
* row (same shape as /jobs/fetch, so toJobView applies), its suggested
* candidates (newest ats_results row per person, best score first) and the
* Suggested / Top Match header stats.
*/
export function fetchProfile(jobPostId, { search, top, limit, skip } = {}) {
return request('/jobs/profile/fetch', {
params: { job_post_id: jobPostId, search, top, limit, skip },
})
}
/** ats_results.band labels, best first — the backend's MATCH_BANDS. */
export const MATCH_BANDS = ['Strong Match', 'Potential Match', 'Weak Match']
export const SUGGESTED_SOURCE_LABEL = { inbox: 'Email', form: 'Sheet Form', upload: 'Uploaded', bank: 'CV Bank' }
/** One suggested candidate -> what the job profile card renders. */
export function toSuggestedView(row) {
return {
id: row.id,
userId: row.user_id || null,
scoredCandidateId: row.scored_candidate_id || null,
name: row.name || row.email || 'Unknown',
email: row.email || null,
currentTitle: row.current_title || null,
currentCompany: row.current_company || null,
experience: row.years_experience ?? null,
score: row.match_score ?? null,
band: row.band || null,
matchedSkills: Array.isArray(row.matched_keywords) ? row.matched_keywords : [],
missingSkills: Array.isArray(row.missing_keywords) ? row.missing_keywords : [],
optionalMatched: Array.isArray(row.optional_matched) ? row.optional_matched : [],
summary: row.summary || null,
source: row.source || null,
sourceLabel: SUGGESTED_SOURCE_LABEL[row.source] ?? row.source ?? '—',
scoredAt: toDate(row.created_at),
}
}
/** Status-change audit for one requisition — GET /jobs/status-history/fetch. */ /** Status-change audit for one requisition — GET /jobs/status-history/fetch. */
export function listStatusHistory(jobPostId) { export function listStatusHistory(jobPostId) {
return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } }) return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } })

View File

@ -87,6 +87,7 @@ export const qk = {
list: (p = {}) => ['jobs', 'list', p], list: (p = {}) => ['jobs', 'list', p],
requisitionStatuses: () => ['jobs', 'requisition-statuses'], requisitionStatuses: () => ['jobs', 'requisition-statuses'],
statusHistory: (id) => ['jobs', 'status-history', id], statusHistory: (id) => ['jobs', 'status-history', id],
profile: (id, p = {}) => ['jobs', 'profile', id, p],
stats: (p = {}) => ['jobs', 'stats', p], stats: (p = {}) => ['jobs', 'stats', p],
}, },
talent: { talent: {

View File

@ -27,7 +27,7 @@ const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' }
const PAGE_SIZE_MAX = 100 const PAGE_SIZE_MAX = 100
/** Extraction keeps the resume's own casing; SHOUTED headers read badly on cards. */ /** Extraction keeps the resume's own casing; SHOUTED headers read badly on cards. */
function displayName(name) { export function displayName(name) {
if (!name || /[a-z]/.test(name)) return name if (!name || /[a-z]/.test(name)) return name
return name.toLowerCase().replace(/\p{L}+/gu, (w) => w[0].toUpperCase() + w.slice(1)) return name.toLowerCase().replace(/\p{L}+/gu, (w) => w[0].toUpperCase() + w.slice(1))
} }
@ -42,7 +42,7 @@ function ringColor(score) {
} }
/** The 120px .ats-ring shrunk to card size — same conic trick, no new CSS. */ /** The 120px .ats-ring shrunk to card size — same conic trick, no new CSS. */
function MiniRing({ score, size = 46 }) { export function MiniRing({ score, size = 46 }) {
return ( return (
<div <div
style={{ style={{

View File

@ -0,0 +1,534 @@
/* ============================================================
JobProfile full-page job profile at /job/:jobId (replaces the Jobs
board's detail modal, per the "Job Profile" design).
One request, GET /jobs/profile/fetch, feeds the whole page: the requisition
row, the suggested candidates and the Suggested / Top Match header stats.
Suggested = the newest ats_results score per person for this job; Top Match
= how many of them sit in the Strong Match band. Search and the "Show" size
go to the API as `search` / `top`; band filter and sort then run client-side
over the rows that come back.
Tabs: Details · Suggested Candidates · History (?tab= deep-links a tab).
============================================================ */
import { useMemo, useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Tabs } from '../ui/Tabs'
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import { platformLabel } from '../lib/platforms'
import { fmtShort } from '../lib/format'
import { avatarColor, initials as initialsOf } from '../data/seed'
import * as jobsApi from '../api/jobs'
import * as assignmentsApi from '../api/assignments'
import * as offersApi from '../api/offers'
import { EditJobForm, JobCover, JobHistory, JobOwnership, SECTION_LABEL } from './Jobs'
import { MiniRing, ScoredCandidateDetail, displayName } from './JobCandidates'
/* The department name as the hero line and the Details tab show it. Kept local
rather than imported from Jobs.jsx so this page does not break when that
screen's helpers are reshuffled. */
function deptValue(j) {
return String(j.requisitionDepartment || j.department || '').trim()
}
function deptLabel(j) {
return deptValue(j) || '—'
}
const TAB_KEYS = ['details', 'suggested', 'history']
const SORTS = [
{ value: 'score', label: 'Best Match → Worst Match' },
{ value: 'recent', label: 'Most Recent' },
{ value: 'name', label: 'A → Z' },
]
export default function JobProfile() {
const { jobId } = useParams()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const { toast } = useToast()
const { can } = useAuth()
const qc = useQueryClient()
const canEdit = can('jobs.edit')
const canDelete = can('jobs.delete')
const [editing, setEditing] = useState(false)
const requestedTab = String(searchParams.get('tab') || '').toLowerCase()
const tab = TAB_KEYS.includes(requestedTab) ? requestedTab : 'details'
const setTab = (next) => {
const params = new URLSearchParams(searchParams)
if (next === 'details') params.delete('tab')
else params.set('tab', next)
setSearchParams(params, { replace: true })
}
// Suggested-list request params. They live on the page, not in the tab,
// because the one profile call carries them.
const [search, setSearch] = useState('')
const [top, setTop] = useState(DEFAULT_PAGE_SIZE)
const [skip, setSkip] = useState(0)
const params = { search: search.trim() || undefined, top, skip }
const profileQuery = useQuery({
queryKey: qk.jobs.profile(jobId, params),
queryFn: async () => (await jobsApi.fetchProfile(jobId, params))?.data ?? null,
enabled: Boolean(jobId),
retry: false,
// A new search or size must not blank the page already on screen.
placeholderData: keepPreviousData,
})
const profile = profileQuery.data
const job = useMemo(() => (profile?.job ? jobsApi.toJobView(profile.job) : null), [profile])
const suggested = useMemo(
() => (Array.isArray(profile?.candidates) ? profile.candidates.map(jobsApi.toSuggestedView) : []),
[profile],
)
const statusesQuery = useQuery({
queryKey: qk.jobs.requisitionStatuses(),
queryFn: async () => {
const res = await jobsApi.listRequisitionStatuses()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.length ? rows : jobsApi.REQUISITION_STATUSES
},
})
const statusLabels = (statusesQuery.data ?? jobsApi.REQUISITION_STATUSES).map((s) => s.label)
// Shared key documented on jobsApi.fetchDepartmentOptions same fetcher everywhere.
const departmentsQuery = useQuery({
queryKey: qk.jobs.list({ scope: 'departments' }),
queryFn: jobsApi.fetchDepartmentOptions,
enabled: editing,
})
const historyQuery = useQuery({
queryKey: qk.assignments.job(jobId),
queryFn: async () => {
const res = await assignmentsApi.listJob(jobId, { currentOnly: false })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((r) => assignmentsApi.toAssignmentView(r))
},
enabled: Boolean(jobId),
retry: false,
})
const statusQuery = useQuery({
queryKey: qk.jobs.statusHistory(jobId),
queryFn: async () => {
const res = await jobsApi.listStatusHistory(jobId)
return Array.isArray(res?.data) ? res.data : []
},
enabled: Boolean(jobId),
retry: false,
})
const offersQuery = useQuery({
queryKey: qk.offers.list({ jobPostId: jobId, top: 200 }),
queryFn: async () => {
const res = await offersApi.list({ jobPostId: jobId, top: 200 })
return Array.isArray(res?.data) ? res.data : []
},
enabled: Boolean(jobId),
retry: false,
})
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0) + (offersQuery.data?.length ?? 0)
const updateJob = useMutation({
mutationFn: (body) => jobsApi.update(jobId, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
setEditing(false)
toast('Job updated', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update the job'), 'error'),
})
const setJobStatus = useMutation({
mutationFn: (status) => jobsApi.setStatus(jobId, status),
onSuccess: (_d, status) => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.notifications.all() })
toast(`Status set to ${status}`, 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
})
const deleteJob = useMutation({
mutationFn: () => jobsApi.remove(jobId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
toast('Job deleted', 'success')
navigate('/jobs', { replace: true })
},
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the job'), 'error'),
})
const goBack = () => (window.history.length > 1 ? navigate(-1) : navigate('/jobs'))
if (profileQuery.isPending || profileQuery.isError || !job) {
return (
<div className="cand-page">
<div className="cand-page-bar">
<button className="btn btn-secondary btn-sm" onClick={goBack}><Icon name="chevron-left" /> Back</button>
<div className="cand-page-crumb"><Link to="/jobs">Jobs</Link></div>
</div>
<div className="card">
<div className="card-body">
{profileQuery.isPending ? (
<SkeletonRows rows={6} />
) : (
<EmptyState icon="briefcase" title="Couldnt load this job">
{friendlyAuthError(profileQuery.error, 'The job may have been deleted or is outside your scope.')}
</EmptyState>
)}
</div>
</div>
</div>
)
}
const roleLine = [deptValue(job), job.location, job.type].filter(Boolean).join(' · ')
return (
<div className="cand-page">
<div className="cand-page-bar">
<button className="btn btn-secondary btn-sm" onClick={goBack}><Icon name="chevron-left" /> Back</button>
<div className="cand-page-crumb">
<Link to="/jobs">Jobs</Link> <span></span> <strong>{job.title}</strong>
</div>
</div>
<div className="card">
<div className="card-body">
<div className="profile-hero job-hero">
<span className="job-hero-icn"><Icon name="briefcase" /></span>
<div className="job-hero-id">
<div className="ph-name">{job.title}</div>
<div className="ph-role">{roleLine || '—'}</div>
<div className="ph-tags">
<Badge>{job.status}</Badge>
<Badge className="b-gray">{job.vacancies ?? 0} {job.vacancies === 1 ? 'Vacancy' : 'Vacancies'}</Badge>
<Badge className="b-gray">{job.applicantCount} {job.applicantCount === 1 ? 'Applicant' : 'Applicants'}</Badge>
</div>
</div>
<div className="job-hero-actions">
<div className="hero-stat">
<div className="v">{profile.total ?? profile.suggested ?? 0}</div>
<div className="l">Suggested</div>
</div>
<div
className="hero-stat"
title={profile.top_score != null ? `Candidates in the Strong Match band · best score ${profile.top_score}` : 'Candidates in the Strong Match band'}
>
<div className="v" style={{ color: profile.top_match ? 'var(--success)' : undefined }}>{profile.top_match ?? 0}</div>
<div className="l">Top Match</div>
</div>
{canEdit ? (
<select
className="select"
aria-label="Requisition status"
value={job.status}
disabled={setJobStatus.isPending}
onChange={(e) => setJobStatus.mutate(e.target.value)}
>
{statusLabels.map((s) => <option key={s}>{s}</option>)}
</select>
) : null}
{canDelete && (
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)' }}
disabled={deleteJob.isPending}
onClick={() => { if (window.confirm(`Delete “${job.title}”?`)) deleteJob.mutate() }}
>
<Icon name="trash" /> {deleteJob.isPending ? 'Deleting…' : 'Delete'}
</button>
)}
{canEdit && (
<button className="btn btn-secondary" onClick={() => setEditing(true)}><Icon name="edit" /> Edit</button>
)}
<button className="btn btn-primary" onClick={() => navigate('/jobboard', { state: { publishJob: job.id } })}>
<Icon name="send" /> Publish
</button>
</div>
</div>
<Tabs
value={tab}
onChange={setTab}
tabs={[
{ key: 'details', label: 'Details' },
{ key: 'suggested', label: 'Suggested Candidates', count: (profile.total ?? profile.suggested) || undefined },
{ key: 'history', label: 'History', count: historyCount || undefined },
]}
/>
<div className="tab-pane active">
{tab === 'details' && <JobDetailsTab job={job} canEdit={canEdit} />}
{tab === 'suggested' && (
<SuggestedCandidatesTab
job={job}
profile={profile}
candidates={suggested}
search={search}
setSearch={(v) => { setSearch(v); setSkip(0) }}
top={top}
skip={skip}
setSkip={setSkip}
setTop={(n) => {
const page = pageAfterSizeChange(Math.floor(skip / top) + 1, profile.total ?? 0, n)
setTop(n)
setSkip((page - 1) * n)
}}
/>
)}
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} offersQuery={offersQuery} />}
</div>
</div>
</div>
{editing && (
<EditJobForm
job={job}
departmentOptions={departmentsQuery.data ?? []}
busy={updateJob.isPending}
onClose={() => setEditing(false)}
onSubmit={(body) => updateJob.mutate(body)}
/>
)}
</div>
)
}
function JobDetailsTab({ job: j, canEdit }) {
const created = [j.created ? fmtShort(j.created) : null, j.createdByName ? `by ${j.createdByName}` : null]
.filter(Boolean)
.join(' · ')
return (
<>
<JobCover jobId={j.id} />
<div className="info-grid mb-18">
<div className="info-item"><div className="il">Department</div><div className="iv">{deptLabel(j)}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div>
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div>
<div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || '—'}</div></div>
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? '—'}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
<div className="info-item"><div className="il">Created</div><div className="iv">{created || '—'}</div></div>
<div className="info-item"><div className="il">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</div></div>
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
<div className="info-item"><div className="il">Assigned Recruiters</div><div className="iv">{j.recruiter || '—'}</div></div>
{j.closedAt && (
<div className="info-item"><div className="il">Closed at</div><div className="iv">{fmtShort(j.closedAt)}</div></div>
)}
</div>
<JobOwnership job={j} canEdit={canEdit} />
{j.description && (
<>
<div className="divider" />
<div className="mb-16">
<div style={SECTION_LABEL}>Description</div>
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
</div>
</>
)}
{j.skills.length > 0 && (
<div className="mb-16">
<div style={SECTION_LABEL}>Required Skills</div>
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</div>
)}
{j.optionalSkills.length > 0 && (
<div>
<div style={SECTION_LABEL}>Optional Skills</div>
<div className="k-tags">
{j.optionalSkills.map((s) => (
<span className="tag tag-optional" key={s}><Icon name="star" /> {s}</span>
))}
</div>
</div>
)}
</>
)
}
function SuggestedCandidatesTab({ job, profile, candidates, search, setSearch, top, skip, setSkip, setTop }) {
const navigate = useNavigate()
const [band, setBand] = useState('')
const [sort, setSort] = useState('score')
const [viewing, setViewing] = useState(null)
// Search already ran on the server; band and sort apply to what came back.
const list = useMemo(() => {
let rows = candidates.filter((c) => !band || c.band === band)
if (sort === 'name') rows = [...rows].sort((a, b) => a.name.localeCompare(b.name))
else if (sort === 'recent') rows = [...rows].sort((a, b) => (b.scoredAt?.getTime() ?? 0) - (a.scoredAt?.getTime() ?? 0))
else rows = [...rows].sort((a, b) => (b.score ?? -1) - (a.score ?? -1))
return rows
}, [candidates, band, sort])
// Paging comes from the API: `total` counts every match, the page holds `top`.
const total = profile.total ?? candidates.length
const pages = Math.max(1, Math.ceil(total / top))
const page = Math.floor(skip / top) + 1
const from = total === 0 ? 0 : skip + 1
const to = Math.min(skip + candidates.length, total)
const bands = profile.bands || {}
function open(c) {
if (c.userId) {
navigate(`/candidate/${c.userId}`)
return
}
setViewing(c)
}
return (
<>
<div className="cand-sub">
{total} candidate{total === 1 ? '' : 's'} suggested · vs {job.title}
{sort === 'score' && <span className="auto-tag">Sorted: Best Worst</span>}
</div>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search name, email, title, company…" />
</div>
<select className="select" aria-label="Match band" value={band} onChange={(e) => setBand(e.target.value)}>
<option value="">All results</option>
{jobsApi.MATCH_BANDS.map((b) => (
<option key={b} value={b}>{b} ({bands[b] ?? 0})</option>
))}
</select>
<div className="spacer" />
<div className="flex items-center gap-8">
<label className="text-muted text-sm" htmlFor="suggested-sort">Sort:</label>
<select
id="suggested-sort"
className={`select${sort === 'score' ? ' active-filter' : ''}`}
value={sort}
onChange={(e) => setSort(e.target.value)}
>
{SORTS.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
{list.length === 0 ? (
candidates.length === 0 && !search.trim() ? (
<EmptyState icon="users" title="No suggested candidates yet">
Candidates appear here once their CVs are ATS-scored against this job.
</EmptyState>
) : (
<EmptyState title="No matches">Try a different search or band.</EmptyState>
)
) : (
<div className="grid g-3">
{list.map((c, i) => (
<SuggestedCard key={c.id} c={c} rank={sort === 'score' ? skip + i + 1 : null} onOpen={() => open(c)} />
))}
</div>
)}
{total > 0 && (
<Pagination
from={from}
to={to}
total={total}
page={page}
pages={pages}
setPage={(p) => setSkip((p - 1) * top)}
pageButtons={pageWindow(page, pages)}
pageSize={top}
pageSizeMax={100}
onPageSizeChange={setTop}
/>
)}
{viewing && (
<ScoredCandidateDetail
candidate={{
id: viewing.id,
name: viewing.name,
filename: viewing.email,
source: viewing.sourceLabel,
currentTitle: viewing.currentTitle,
currentCompany: viewing.currentCompany,
experience: viewing.experience,
aiScore: viewing.score,
matchedSkills: viewing.matchedSkills,
missingSkills: viewing.missingSkills,
critique: viewing.summary,
scoringStatus: 'completed',
applied: viewing.scoredAt,
}}
jobTitle={job.title}
onClose={() => setViewing(null)}
/>
)}
</>
)
}
function SuggestedCard({ c, rank, onOpen }) {
const matched = c.matchedSkills.slice(0, 3)
const missing = c.missingSkills.slice(0, 2)
const optional = c.optionalMatched.slice(0, 3)
const more = (c.matchedSkills.length - matched.length)
+ (c.missingSkills.length - missing.length)
+ (c.optionalMatched.length - optional.length)
const roleLine = [c.currentTitle, c.currentCompany].filter(Boolean).join(' at ')
const foot = [c.experience != null ? `${c.experience} yrs` : null, c.currentCompany].filter(Boolean).join(' · ')
return (
<div
className={`card cand-card${rank ? ' ranked' : ''}`}
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen() } }}
>
<div className="card-body">
{rank && <span className="cand-rank">{rank}</span>}
<div className="cand-head">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div className="cand-id">
<div className="cand-name">{displayName(c.name)}</div>
<div className="cand-role">{roleLine || c.email || '—'}</div>
</div>
{c.score != null && <MiniRing score={c.score} />}
</div>
<div className="cand-skills">
{matched.map((s) => <span className="cand-chip ok" key={`m-${s}`}><Icon name="check" /> {s}</span>)}
{missing.map((s) => <span className="cand-chip miss" key={`x-${s}`}><Icon name="x" /> {s}</span>)}
{optional.map((s) => (
<span className="cand-chip opt" key={`o-${s}`} title="Optional skill from the job post"><Icon name="star" /> {s}</span>
))}
{more > 0 && <span className="cand-chip more">+{more} more</span>}
</div>
<p className="cand-crit">{c.summary || <span className="text-muted">No summary</span>}</p>
<div className="cand-foot">
<span className="cand-company">{foot || '—'}</span>
<Badge className="b-gray">{c.sourceLabel}</Badge>
</div>
</div>
</div>
)
}

View File

@ -15,7 +15,6 @@ import AiFieldAssist from '../ui/AiFieldAssist'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs'
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives' import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
@ -25,7 +24,6 @@ import { friendlyAuthError } from '../lib/errors'
import { platformLabel } from '../lib/platforms' import { platformLabel } from '../lib/platforms'
import * as jobsApi from '../api/jobs' import * as jobsApi from '../api/jobs'
import * as jobPostsApi from '../api/jobPosts' import * as jobPostsApi from '../api/jobPosts'
import * as assignmentsApi from '../api/assignments'
import * as tasksApi from '../api/tasks' import * as tasksApi from '../api/tasks'
import * as usersApi from '../api/users' import * as usersApi from '../api/users'
import * as requisitionsApi from '../api/requisitions' import * as requisitionsApi from '../api/requisitions'
@ -44,10 +42,10 @@ async function fetchJobs() {
} }
function deptValue(j) { function deptValue(j) {
return String(j.department || '').trim() return String(j.requisitionDepartment || j.department || '').trim()
} }
function deptLabel(j) { export function deptLabel(j) {
return deptValue(j) || '—' return deptValue(j) || '—'
} }
@ -108,31 +106,27 @@ export default function Jobs() {
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
const [type, setType] = useState('') const [type, setType] = useState('')
const [viewing, setViewing] = useState(null)
const [viewingTab, setViewingTab] = useState('details')
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const canEdit = can('jobs.edit') const canEdit = can('jobs.edit')
const canDelete = can('jobs.delete') const openJob = (id, tab) => navigate(`/job/${id}${tab === 'history' ? '?tab=history' : ''}`)
// Deep-link intents from notifications, global search, the dashboard and the // Deep-link intents from notifications, global search, the dashboard and the
// manager portal. Consume once and replace history: jobs refetch after a // manager portal. Consume once and replace history: jobs refetch after a
// status PATCH used to replay openCreate and pop the create modal over the // status PATCH used to replay openCreate and pop the create modal. A job
// detail view. `/jobs?job=` / `?tab=history` is the notification target. // target (`/jobs?job=` / `?tab=history`, or state.openJob) now redirects to
// the full job profile page at /job/:jobId.
useEffect(() => { useEffect(() => {
const st = location.state const st = location.state
const jobId = searchParams.get('job') || st?.openJob const jobId = searchParams.get('job') || st?.openJob
const tab = String(searchParams.get('tab') || '').toLowerCase() const tab = String(searchParams.get('tab') || '').toLowerCase()
if (!st?.openCreate && !jobId) return if (!st?.openCreate && !jobId) return
if (st?.openCreate) setCreating(true)
if (jobId) { if (jobId) {
const job = jobs.find((j) => j.id === jobId) navigate(`/job/${jobId}${tab === 'history' ? '?tab=history' : ''}`, { replace: true })
if (job) { return
setViewing(job)
setViewingTab(tab === 'history' ? 'history' : 'details')
} else if (!jobsQuery.isSuccess) return
} }
if (st?.openCreate) setCreating(true)
const next = new URLSearchParams(searchParams) const next = new URLSearchParams(searchParams)
let queryChanged = false let queryChanged = false
if (next.has('job')) { if (next.has('job')) {
@ -144,15 +138,8 @@ export default function Jobs() {
queryChanged = true queryChanged = true
} }
if (queryChanged) setSearchParams(next, { replace: true }) if (queryChanged) setSearchParams(next, { replace: true })
if (st?.openJob || st?.openCreate) navigate('.', { replace: true, state: null }) if (st?.openCreate) navigate('.', { replace: true, state: null })
}, [location.state, searchParams, jobs, jobsQuery.isSuccess, navigate, setSearchParams]) }, [location.state, searchParams, navigate, setSearchParams])
useEffect(() => {
if (!viewing) return
const fresh = jobs.find((j) => j.id === viewing.id)
if (fresh) setViewing(fresh)
else if (jobsQuery.isSuccess) setViewing(null)
}, [jobs]) // eslint-disable-line react-hooks/exhaustive-deps
const createJob = useMutation({ const createJob = useMutation({
mutationFn: async ({ payload, imageFile }) => { mutationFn: async ({ payload, imageFile }) => {
@ -211,17 +198,6 @@ export default function Jobs() {
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'), onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
}) })
const deleteJob = useMutation({
mutationFn: (id) => jobsApi.remove(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
setViewing(null)
toast('Job deleted', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the job'), 'error'),
})
const departmentOptions = useMemo( const departmentOptions = useMemo(
() => [...new Set(jobs.map(deptValue).filter(Boolean))].sort(), () => [...new Set(jobs.map(deptValue).filter(Boolean))].sort(),
[jobs], [jobs],
@ -293,7 +269,7 @@ export default function Jobs() {
key: '_a', label: 'Actions', align: 'right', key: '_a', label: 'Actions', align: 'right',
render: (j) => ( render: (j) => (
<div className="row-actions"> <div className="row-actions">
<button className="act-btn" data-tip="View" aria-label="View job" onClick={(e) => { e.stopPropagation(); setViewing(j) }}><Icon name="eye" /></button> <button className="act-btn" data-tip="View" aria-label="View job" onClick={(e) => { e.stopPropagation(); openJob(j.id) }}><Icon name="eye" /></button>
{canEdit && ( {canEdit && (
<button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={(e) => { e.stopPropagation(); setEditing(j) }}><Icon name="edit" /></button> <button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={(e) => { e.stopPropagation(); setEditing(j) }}><Icon name="edit" /></button>
)} )}
@ -382,32 +358,12 @@ export default function Jobs() {
rows={rows} rows={rows}
pageSize={50} pageSize={50}
empty="No requisitions match these filters." empty="No requisitions match these filters."
onRowClick={(j) => setViewing(j)} onRowClick={(j) => openJob(j.id)}
/> />
</> </>
)} )}
</div> </div>
{viewing && (
<JobDetail
key={viewing.id}
job={viewing}
initialTab={viewingTab}
canEdit={canEdit}
canDelete={canDelete}
statusBusy={setJobStatus.isPending}
deleteBusy={deleteJob.isPending}
onClose={() => { setViewing(null); setViewingTab('details') }}
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
onEdit={() => { setEditing(viewing); setViewing(null) }}
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
statusLabels={statusLabels}
onDelete={() => {
if (window.confirm(`Delete “${viewing.title}”?`)) deleteJob.mutate(viewing.id)
}}
/>
)}
{editing && ( {editing && (
<EditJobForm <EditJobForm
job={editing} job={editing}
@ -428,7 +384,7 @@ export default function Jobs() {
) )
} }
const SECTION_LABEL = { export const SECTION_LABEL = {
fontSize: 12, color: 'var(--text-3)', fontWeight: 600, fontSize: 12, color: 'var(--text-3)', fontWeight: 600,
textTransform: 'uppercase', marginBottom: 6, textTransform: 'uppercase', marginBottom: 6,
} }
@ -1121,7 +1077,7 @@ function JobForm({ busy, onClose, onSubmit }) {
) )
} }
function EditJobForm({ job: j, busy, onClose, onSubmit }) { export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
const managersQuery = useManagerDirectory() const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory() const recruitersQuery = useRecruiterDirectory()
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker( const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
@ -1323,7 +1279,7 @@ function EditJobForm({ job: j, busy, onClose, onSubmit }) {
* Hiring-manager + recruiter pointers on one requisition. * Hiring-manager + recruiter pointers on one requisition.
* Writes go through PATCH /jobs/update; job_assignments keeps the interval log. * Writes go through PATCH /jobs/update; job_assignments keeps the interval log.
*/ */
function JobOwnership({ job, canEdit }) { export function JobOwnership({ job, canEdit }) {
const { toast } = useToast() const { toast } = useToast()
const qc = useQueryClient() const qc = useQueryClient()
const managersQuery = useManagerDirectory() const managersQuery = useManagerDirectory()
@ -1396,7 +1352,7 @@ function JobOwnership({ job, canEdit }) {
) )
} }
function JobHistory({ historyQuery, statusQuery, offersQuery }) { export function JobHistory({ historyQuery, statusQuery, offersQuery }) {
const assignments = historyQuery.data ?? [] const assignments = historyQuery.data ?? []
const statusRows = statusQuery.data ?? [] const statusRows = statusQuery.data ?? []
const offerRows = offersQuery?.data ?? [] const offerRows = offersQuery?.data ?? []
@ -1522,7 +1478,7 @@ function AssignmentHistoryRow({ row }) {
/* Cover image, when the post has one fetched with the bearer token into an /* Cover image, when the post has one fetched with the bearer token into an
object URL, because a bare <img src> cannot carry auth headers. null (404) object URL, because a bare <img src> cannot carry auth headers. null (404)
simply renders nothing. */ simply renders nothing. */
function JobCover({ jobId }) { export function JobCover({ jobId }) {
const [url, setUrl] = useState(null) const [url, setUrl] = useState(null)
useEffect(() => { useEffect(() => {
let alive = true let alive = true
@ -1539,136 +1495,3 @@ function JobCover({ jobId }) {
if (!url) return null if (!url) return null
return <img src={url} alt="Job cover" className="job-cover" /> return <img src={url} alt="Job cover" className="job-cover" />
} }
function JobDetail({
job: j, initialTab = 'details', canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
statusLabels = jobsApi.JOB_STATUSES,
}) {
const [tab, setTab] = useState(initialTab === 'history' ? 'history' : 'details')
const historyQuery = useQuery({
queryKey: qk.assignments.job(j.id),
queryFn: async () => {
const res = await assignmentsApi.listJob(j.id, { currentOnly: false })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((r) => assignmentsApi.toAssignmentView(r))
},
enabled: Boolean(j.id),
retry: false,
})
const statusQuery = useQuery({
queryKey: qk.jobs.statusHistory(j.id),
queryFn: async () => {
const res = await jobsApi.listStatusHistory(j.id)
return Array.isArray(res?.data) ? res.data : []
},
enabled: Boolean(j.id),
retry: false,
})
const offersQuery = useQuery({
queryKey: qk.offers.list({ jobPostId: j.id, top: 200 }),
queryFn: async () => {
const res = await offersApi.list({ jobPostId: j.id, top: 200 })
return Array.isArray(res?.data) ? res.data : []
},
enabled: Boolean(j.id),
retry: false,
})
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0) + (offersQuery.data?.length ?? 0)
return (
<Modal
title="Job Details"
subtitle={deptValue(j) || undefined}
size="modal-lg"
onClose={onClose}
footer={
<>
{canDelete && (
<button className="btn btn-ghost" style={{ color: 'var(--danger)', marginRight: 'auto' }} onClick={onDelete} disabled={deleteBusy}>
<Icon name="trash" /> {deleteBusy ? 'Deleting…' : 'Delete'}
</button>
)}
<button className="btn btn-secondary" onClick={onClose}>Close</button>
{canEdit && (
<button className="btn btn-secondary" onClick={onEdit}><Icon name="edit" /> Edit</button>
)}
<button className="btn btn-secondary" onClick={onPublish}><Icon name="send" /> Publish</button>
</>
}
>
<JobCover jobId={j.id} />
<div className="flex items-center gap-16 mb-18">
<span className="kpi-icn i-indigo" style={{ width: 52, height: 52, borderRadius: 14 }}>
<Icon name="briefcase" />
</span>
<div>
<div style={{ fontSize: 19, fontWeight: 700 }}>{j.title}</div>
<div className="text-muted">{[deptValue(j), j.location].filter(Boolean).join(' · ') || '—'}</div>
</div>
<div style={{ marginLeft: 'auto' }}>
{canEdit ? (
<select
className="select"
value={j.status}
disabled={statusBusy}
onChange={(e) => onStatus(e.target.value)}
>
{statusLabels.map((s) => <option key={s}>{s}</option>)}
</select>
) : (
<Badge>{j.status}</Badge>
)}
</div>
</div>
<Tabs
value={tab}
onChange={setTab}
tabs={[
{ key: 'details', label: 'Details' },
{ key: 'history', label: 'History', count: historyCount || undefined },
]}
/>
{tab === 'details' && (
<>
<div className="info-grid mb-18">
<div className="info-item"><div className="il">Department</div><div className="iv">{deptLabel(j)}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div>
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div>
<div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || '—'}</div></div>
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? '—'}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
<div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div>
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
<div className="info-item"><div className="il">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</div></div>
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
<div className="info-item"><div className="il">Assigned Recruiters</div><div className="iv">{j.recruiter || '—'}</div></div>
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
</div>
<JobOwnership job={j} canEdit={canEdit} />
{j.description && (
<>
<div className="divider" />
<div className="mb-16">
<div style={SECTION_LABEL}>Description</div>
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
</div>
</>
)}
{!!(j.skills && j.skills.length) && (
<div>
<div style={SECTION_LABEL}>Required Skills</div>
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</div>
)}
</>
)}
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} offersQuery={offersQuery} />}
</Modal>
)
}

View File

@ -1,35 +1,4 @@
/* ============================================================
Recruiter Hub live, by pointing every analytics endpoint at one recruiter.
The trick that makes this screen real: /analytics/kpis, /hiring-trend and
/funnel all take a `recruiter_id`, so selecting a recruiter re-scopes the
whole page server-side rather than filtering a client-side array. The
recruiter list itself is /analytics/recruiter-performance, which is also the
leaderboard.
TEN OF THE PROTOTYPE'S EIGHTEEN TILES ARE GONE. workload %, efficiency %, SLA
state, interview completion %, avg response time, TAT %, star rating, jobs
awaiting approval and jobs overdue have no column, no table and in most cases
no concept behind them there is no approval workflow and no requisition
deadline in the schema. They were random numbers re-rolled on every render.
What replaced them is derived from real counts and labelled as such:
conversion rate is hires ÷ candidates, offer acceptance is accepted ÷ sent.
The workload heatmap survived because interviews are real: it buckets
/interview/fetch over the last five weeks by weekday. It is TEAM-WIDE, not
per recruiter the interviews table has no recruiter column and the card
says so rather than implying the selected person owns all of it.
Tasks belong here too: GET /tasks/fetch?assignee_id= the selected recruiter
is the worklist the prototype filed under Recruiter Hub. Completing a row
writes the same /tasks/update the Tasks screen uses, so the two stay in
sync. Hidden without tasks.view; the rest of the hub still loads.
Interviews Today / upcoming / the heatmap join interviews job_posts via
COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id) and
filter on current_recruiter_id. The leaderboard ranks by completed
requisitions (requisition_status=completed), not inbox hires.
============================================================ */
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'

View File

@ -1422,6 +1422,30 @@ canvas { width: 100%; max-width: 100%; display: block; }
.cand-meta svg { width: 13px; height: 13px; } .cand-meta svg { width: 13px; height: 13px; }
.cand-company { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-3); } .cand-company { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-3); }
/* Job profile page (JobProfile.jsx) hero stats, ranked suggested-candidate
cards and the optional-skill highlight shared by cards and the Details tab. */
.cand-page-crumb a { color: var(--text-3); }
.cand-page-crumb a:hover { color: var(--text); }
.job-hero { flex-wrap: wrap; margin-bottom: 18px; }
.job-hero-icn { width: 60px; height: 60px; border-radius: 14px; flex: none; display: grid; place-items: center; background: var(--primary-soft); color: var(--primary); }
.job-hero-icn svg { width: 26px; height: 26px; }
.job-hero-id { min-width: 0; }
.job-hero .ph-name { overflow-wrap: anywhere; }
.job-hero-actions { margin-left: auto; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.hero-stat { text-align: center; min-width: 60px; }
.hero-stat .v { font-family: var(--font-display); font-size: 22px; font-weight: 600; line-height: 1.1; }
.hero-stat .l { font-size: 11px; color: var(--text-3); margin-top: 2px; }
.cand-sub { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 16px 0 12px; font-size: 13px; color: var(--text-2); }
.auto-tag { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; padding: 2px 7px; border-radius: 6px; background: var(--primary-soft); color: var(--primary); }
.select.active-filter { border-color: var(--primary); color: var(--primary); font-weight: 600; }
.cand-card .card-body { position: relative; }
.cand-rank { position: absolute; top: 12px; left: 12px; width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-sunken); color: var(--text-2); font-size: 11px; font-weight: 700; }
.cand-card.ranked .cand-head { padding-left: 22px; }
.cand-chip.ok { background: var(--success-soft); color: var(--success); }
.cand-chip.opt { background: var(--purple-soft); color: var(--purple); }
.tag.tag-optional { display: inline-flex; align-items: center; gap: 4px; background: var(--purple-soft); color: var(--purple); }
.tag.tag-optional svg { width: 11px; height: 11px; }
/* Find Talent toolbar (Talent.jsx): the job picker takes the slack, the /* Find Talent toolbar (Talent.jsx): the job picker takes the slack, the
location controls hold a readable fixed width. The widths live here, not location controls hold a readable fixed width. The widths live here, not
inline, so the 640 block can stack everything full-width. */ inline, so the 640 block can stack everything full-width. */
@ -2282,6 +2306,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
@media (max-width: 640px) { @media (max-width: 640px) {
.g-kpi-7 { grid-template-columns: 1fr; } .g-kpi-7 { grid-template-columns: 1fr; }
.cand-page-actions { width: 100%; } .cand-page-actions { width: 100%; }
.job-hero-actions { width: 100%; margin-left: 0; }
.cand-page-actions .btn { flex: 1 1 auto; justify-content: center; } .cand-page-actions .btn { flex: 1 1 auto; justify-content: center; }
.hf-sign-grid { grid-template-columns: 1fr; } .hf-sign-grid { grid-template-columns: 1fr; }
.hf-summary { grid-template-columns: repeat(2, 1fr); } .hf-summary { grid-template-columns: repeat(2, 1fr); }