Merge pull request 'candidates are arrigving' (#94) from Edit_Job_Profile into dev_main
Deploy to S3 / deploy (push) Successful in 39s
Details
Deploy to S3 / deploy (push) Successful in 39s
Details
Reviewed-on: #94Interview_Delay
commit
cf9cf7ca8e
|
|
@ -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 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`,
|
||||
so a module called `alembic.py` would shadow the installed package.
|
||||
"""
|
||||
|
|
@ -21,6 +27,7 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
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.config import Config
|
||||
from alembic.operations import Operations
|
||||
from alembic.operations import ops as alembic_ops
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
from alembic.script.revision import ResolutionError
|
||||
|
|
@ -40,7 +48,15 @@ from alembic.util.exc import CommandError
|
|||
from sqlalchemy import MetaData, text
|
||||
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")
|
||||
|
||||
|
|
@ -182,6 +198,7 @@ def config(connection: Connection | None = None) -> Config:
|
|||
|
||||
VERSION_TABLE = "alembic_version"
|
||||
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:
|
||||
|
|
@ -194,7 +211,7 @@ def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to
|
|||
return False
|
||||
if type_ != "table":
|
||||
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 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)
|
||||
|
||||
|
||||
def _apply_upgrade_ops(connection: Connection) -> int:
|
||||
"""Apply ORM→DB diffs in-process without writing a revision file."""
|
||||
_DESTRUCTIVE_OPS = (
|
||||
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"}
|
||||
ctx = MigrationContext.configure(connection, opts=opts)
|
||||
script = produce_migrations(ctx, target_metadata())
|
||||
if script.upgrade_ops.is_empty():
|
||||
return 0
|
||||
operations = Operations(ctx)
|
||||
applied = 0
|
||||
stack = [script.upgrade_ops]
|
||||
while stack:
|
||||
elem = stack.pop(0)
|
||||
flat: list[Any] = []
|
||||
|
||||
def walk(elem: Any) -> None:
|
||||
if hasattr(elem, "ops"):
|
||||
stack.extend(elem.ops)
|
||||
for child in elem.ops:
|
||||
walk(child)
|
||||
else:
|
||||
operations.invoke(elem)
|
||||
applied += 1
|
||||
return applied
|
||||
flat.append(elem)
|
||||
|
||||
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:
|
||||
|
|
@ -458,6 +507,107 @@ async def run_manual_sql() -> None:
|
|||
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
|
||||
async def _lock() -> AsyncIterator[None]:
|
||||
"""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.
|
||||
logger.exception("ORM drift apply failed; continuing boot: %s", exc)
|
||||
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())
|
||||
|
||||
|
||||
|
|
@ -510,10 +664,15 @@ def main(argv: Sequence[str] | None = None) -> None:
|
|||
"current",
|
||||
"head",
|
||||
"stamp",
|
||||
"sync",
|
||||
],
|
||||
)
|
||||
parser.add_argument("-m", "--message", default="auto", help="revision message")
|
||||
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)
|
||||
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())
|
||||
elif args.command == "stamp":
|
||||
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:
|
||||
await close_db()
|
||||
|
||||
|
|
|
|||
|
|
@ -1359,7 +1359,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
return row
|
||||
|
||||
@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.
|
||||
|
||||
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))
|
||||
.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()}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -2152,6 +2158,109 @@ class AtsResults(SQLModel, table=True):
|
|||
grouped.setdefault(row.form_data_id, []).append(row)
|
||||
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
|
||||
async def job_ids_by_emails(cls, session: AsyncSession, emails) -> dict:
|
||||
"""{email: {job_post_id, ...}} for any prior ATS score of these people."""
|
||||
|
|
|
|||
|
|
@ -153,7 +153,6 @@ class JobUpdate(BaseModel):
|
|||
experience_min: int | None = None
|
||||
experience_max: int | None = None
|
||||
description: str | None = None
|
||||
current_recruiter_id: UUID | None = None
|
||||
current_recruiter_ids: list[UUID] | None = None
|
||||
hiring_manager_id: UUID | None = None
|
||||
requisition_id: UUID | None = None
|
||||
|
|
@ -938,13 +937,16 @@ async def fetch_requisition_statuses(
|
|||
@router.get("/jobs/status-history/fetch")
|
||||
async def fetch_job_status_history(
|
||||
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)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Who changed requisition_status on one job, from what, to what, and when."""
|
||||
try:
|
||||
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})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -984,6 +986,29 @@ async def fetch_jobs(
|
|||
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")
|
||||
async def export_jobs(
|
||||
search: str | None = Query(None),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ JOB_ASSIGNMENT_ROLES = {
|
|||
"hiring_manager": EnumRoles.HIRING_MANAGER,
|
||||
}
|
||||
JOB_OWNER_COLUMN = {
|
||||
"primary_recruiter": "current_recruiter_id",
|
||||
"primary_recruiter": "current_recruiter_ids",
|
||||
"hiring_manager": "hiring_manager_id",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1143,6 +1143,28 @@ class Candidates(SQLModel, table=True):
|
|||
)
|
||||
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
|
||||
async def list_by_emails(cls, session: AsyncSession, emails):
|
||||
"""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_status: str = Field(default="")
|
||||
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")
|
||||
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
||||
graph_event_id: str | None = Field(default=None)
|
||||
|
|
@ -1342,9 +1362,6 @@ class Interviews(SQLModel, table=True):
|
|||
@classmethod
|
||||
def scoped_to_recruiter(cls, statement, recruiter_id):
|
||||
"""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 job.job_post.models import JobPosts
|
||||
|
|
|
|||
|
|
@ -60,11 +60,7 @@ class JobPosts(SQLModel, table=True):
|
|||
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
|
||||
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
# 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(
|
||||
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"},
|
||||
)
|
||||
|
|
@ -94,59 +90,36 @@ class JobPosts(SQLModel, table=True):
|
|||
|
||||
@staticmethod
|
||||
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
|
||||
row that has not been backfilled still maps to one person.
|
||||
Strings because every consumer keys names off str(uuid). Works on a JobPosts
|
||||
row and on a RowMapping from fetch_job_stats; both allow attribute access.
|
||||
"""
|
||||
if isinstance(row, dict):
|
||||
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
|
||||
return [str(i) for i in (getattr(row, "current_recruiter_ids", None) or [])]
|
||||
|
||||
@classmethod
|
||||
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)
|
||||
if uid is None:
|
||||
return false()
|
||||
return or_(
|
||||
cls.current_recruiter_id == uid,
|
||||
cls.current_recruiter_ids.contains([str(uid)]),
|
||||
)
|
||||
return cls.current_recruiter_ids.contains([str(uid)])
|
||||
|
||||
@classmethod
|
||||
def no_recruiters(cls):
|
||||
"""SQL: neither the pointer nor the JSON list names anyone."""
|
||||
return and_(
|
||||
cls.current_recruiter_id.is_(None),
|
||||
func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0,
|
||||
)
|
||||
"""SQL: current_recruiter_ids names nobody — NULL or [].
|
||||
|
||||
coalesce covers both, so this must not be ANDed with an IS NULL test:
|
||||
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
|
||||
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(select(cls).where(cls.id == uid))
|
||||
result = await session.execute(select(cls).where(cls.id == uid).order_by(cls.created_at.desc(),cls.id.desc()))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
|
|
@ -507,9 +480,9 @@ class JobPosts(SQLModel, table=True):
|
|||
.subquery("job_reapplied")
|
||||
)
|
||||
|
||||
# Alias so this join does not collide with the Users join inside
|
||||
# the manual-upload subquery above.
|
||||
Recruiter=aliased(Users)
|
||||
# No recruiter join here: current_recruiter_ids is a JSONB array, so a join
|
||||
# on it would emit one stats row per recruiter and multiply the counts.
|
||||
# The service resolves recruiter names from that column instead.
|
||||
statement = (
|
||||
select(
|
||||
cls.id.label("job_post_id"),
|
||||
|
|
@ -517,10 +490,8 @@ class JobPosts(SQLModel, table=True):
|
|||
cls.department,
|
||||
cls.location,
|
||||
cls.requisition_status,
|
||||
cls.current_recruiter_id,
|
||||
cls.current_recruiter_ids,
|
||||
cls.created_at,
|
||||
Recruiter.name.label("recruiter_name"),
|
||||
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
|
||||
func.coalesce(stats.c.shortlisting, 0).label("shortlisting"),
|
||||
func.coalesce(stats.c.screened, 0).label("screened"),
|
||||
|
|
@ -536,7 +507,6 @@ class JobPosts(SQLModel, table=True):
|
|||
.select_from(cls)
|
||||
.outerjoin(stats, 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
|
||||
)
|
||||
if active_only:
|
||||
|
|
@ -891,15 +861,22 @@ class JobPostStatusHistory(SQLModel, table=True):
|
|||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
|
||||
@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)
|
||||
if uid is None:
|
||||
return []
|
||||
result = await session.execute(
|
||||
statement = (
|
||||
select(cls)
|
||||
.where(cls.job_post_id == uid)
|
||||
.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())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -281,3 +281,75 @@ async def list_buffer_channels() -> list[dict]:
|
|||
"organization_name": org.get("name"),
|
||||
})
|
||||
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 {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,15 +21,20 @@ def serialize_job_post_title(row) -> dict:
|
|||
|
||||
|
||||
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 {}
|
||||
ids = JobPosts.recruiter_ids_of(row)
|
||||
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 {
|
||||
"current_recruiter_id": first,
|
||||
"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],
|
||||
"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:
|
||||
"""Requisition view of a job post, for the Jobs screen.
|
||||
def serialize_job_row(row, *, people=None, applicant_count=0) -> dict:
|
||||
"""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
|
||||
inbox, candidate and matching paths. department is the one shared field —
|
||||
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)
|
||||
payload = _recruiter_payload(row, names)
|
||||
if recruiter_name and not payload["recruiter_name"]:
|
||||
payload["recruiter_name"] = recruiter_name
|
||||
people = people or {}
|
||||
recruiters = people.get("recruiters") or {}
|
||||
hiring_manager = people.get("hiring_manager") or None
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"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,
|
||||
"location": row.location,
|
||||
"employment_type": row.employment_type,
|
||||
"vacancies": row.vacancies,
|
||||
"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,
|
||||
"status": row.status,
|
||||
"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,
|
||||
"is_active": row.is_active,
|
||||
"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_title": req.position_title 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_at": row.created_at.isoformat() if row.created_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,
|
||||
"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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
from typing import Any
|
||||
|
||||
|
||||
from datetime import date, time
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -11,23 +14,28 @@ from fastapi import HTTPException
|
|||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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.candidate.models import Candidates
|
||||
from job.job_post.enums import RequisitionStatus
|
||||
from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform
|
||||
from role.models import EnumRoles
|
||||
from users.models import Users
|
||||
from job.job_post.plugins import (
|
||||
BufferError,
|
||||
job_people_of,
|
||||
create_buffer_post,
|
||||
list_buffer_channels,
|
||||
local_status,
|
||||
normalize_platform,
|
||||
optional_skill_hits,
|
||||
parse_buffer_datetime,
|
||||
render_job_post,
|
||||
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()
|
||||
logger=logging.getLogger("job.job_post")
|
||||
|
|
@ -41,28 +49,20 @@ MAX_JOB_IMAGE_BYTES=5*1024*1024
|
|||
|
||||
|
||||
def _payload_recruiter_ids(payload):
|
||||
"""Prefer current_recruiter_ids; fall back to current_recruiter_id. None = omitted."""
|
||||
has_list="current_recruiter_ids" in payload and payload.get("current_recruiter_ids") is not None
|
||||
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
|
||||
"""None = the client did not send recruiters, so leave them alone.
|
||||
[] = the client sent an empty list, so remove every recruiter.
|
||||
|
||||
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):
|
||||
ids=[str(u.id) for u in users]
|
||||
return {
|
||||
"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
|
||||
due_at: str | None = None
|
||||
hiring_manager_id: UUID | None = None
|
||||
current_recruiter_id: UUID | None = None
|
||||
current_recruiter_ids: list[UUID] | None = None
|
||||
requisition_id: UUID | None = None
|
||||
|
||||
|
|
@ -376,12 +375,12 @@ class JobPost:
|
|||
async def fetch_requisition_statuses(self):
|
||||
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)
|
||||
if not job or job.is_deleted:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id)
|
||||
names=await Users.names_by_ids(self.session,[r.changed_by for r in rows])
|
||||
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],search,top,limit)
|
||||
return [
|
||||
serialize_status_history(r,changed_by_name=names.get(str(r.changed_by)))
|
||||
for r in rows
|
||||
|
|
@ -404,31 +403,80 @@ class JobPost:
|
|||
employment_type=employment_type,hiring_manager_id=hm_uid,
|
||||
restrict_ids=restrict,
|
||||
)
|
||||
names=await Users.names_by_ids(
|
||||
people=await Users.job_people(
|
||||
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])
|
||||
return [
|
||||
serialize_job_row(
|
||||
r,
|
||||
names=names,
|
||||
hiring_manager_name=names.get(str(r.hiring_manager_id)),
|
||||
people=job_people_of(r,people),
|
||||
applicant_count=counts.get(str(r.id),0),
|
||||
)
|
||||
for r in rows
|
||||
],total
|
||||
|
||||
async def _job_row(self,row):
|
||||
names=await Users.names_by_ids(
|
||||
async def fetch_job_profile(self,job_post_id,current_user=None,search=None,top=None,limit=None,skip=None):
|
||||
"""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,
|
||||
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])
|
||||
job_payload=serialize_job_row(
|
||||
job,
|
||||
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,
|
||||
names=names,
|
||||
hiring_manager_name=names.get(str(row.hiring_manager_id)),
|
||||
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):
|
||||
from department.models import Department
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -208,17 +208,10 @@ async def system_admin_ids(session):
|
|||
|
||||
async def job_recruiter_ids(session, job):
|
||||
"""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()
|
||||
if job is None:
|
||||
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 []:
|
||||
extra = _as_uuid(raw)
|
||||
if extra is not None:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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},
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from sqlalchemy.orm import selectinload
|
||||
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
|
||||
|
||||
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()]
|
||||
|
||||
@classmethod
|
||||
async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
||||
"""Resolve {user_id: name} in a single query.
|
||||
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, 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
|
||||
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}
|
||||
if not uids:
|
||||
return {}
|
||||
result = await session.execute(
|
||||
statement =(
|
||||
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()}
|
||||
|
||||
@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
|
||||
async def get_by_ids(cls, session: AsyncSession, ids):
|
||||
"""Users with role selectin-loaded. UUID keys so callers can map by row.assignee_id."""
|
||||
|
|
|
|||
|
|
@ -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 1–50 of 120', m.text().includes('1–50') && 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 101–120 of 120', m.text().includes('101–120'))
|
||||
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('51–100'), 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)
|
||||
|
|
@ -18,7 +18,8 @@
|
|||
"test:cvbank": "node cvbank.test.mjs",
|
||||
"test:browse": "node candidate-browse.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": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ function LegacyCandidateRedirect() {
|
|||
const to = `/candidate/${encodeURIComponent(userId)}`
|
||||
return <Navigate to={tab ? `${to}?tab=${encodeURIComponent(tab)}` : to} replace />
|
||||
}
|
||||
const JobProfile = lazy(() => import('./screens/JobProfile'))
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
|
|
@ -104,6 +105,14 @@ export default function App() {
|
|||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/job/:jobId"
|
||||
element={
|
||||
<RequireAuth permission="jobs.view">
|
||||
<JobProfile />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/candidates/:userId"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import Notifications from '../screens/Notifications'
|
|||
import Rbac from '../screens/Rbac'
|
||||
import Settings from '../screens/Settings'
|
||||
import Help from '../screens/Help'
|
||||
import JobProfile from '../screens/JobProfile'
|
||||
|
||||
const SCREENS = {
|
||||
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
||||
|
|
@ -68,6 +69,11 @@ const PAGES = {
|
|||
'/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 = [
|
||||
...Object.keys(PAGES),
|
||||
...TABLE.map((r) => `/${r.path}`),
|
||||
|
|
@ -112,9 +118,21 @@ export async function mountRoute(path, container) {
|
|||
})
|
||||
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 {
|
||||
settle,
|
||||
type,
|
||||
click,
|
||||
selectOption,
|
||||
html: () => container.innerHTML,
|
||||
|
|
@ -129,15 +147,17 @@ export async function mountRoute(path, container) {
|
|||
function routeTree(path) {
|
||||
const h = React.createElement
|
||||
const isAuth = path.startsWith('/auth/')
|
||||
const def = TABLE.find((r) => `/${r.path}` === path)
|
||||
const Screen = isAuth ? PAGES[path] : SCREENS[def.path]
|
||||
const detail = DETAIL_PAGES.find((d) => path.startsWith(d.prefix))
|
||||
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
|
||||
? h(Route, { path, element: h(Screen) })
|
||||
: h(
|
||||
Route,
|
||||
{ element: h(RequireAuth, null, h(AppLayout)) },
|
||||
h(Route, { path, element: h(Screen) }),
|
||||
h(Route, { path: routePath, element: h(Screen) }),
|
||||
)
|
||||
|
||||
return h(
|
||||
|
|
|
|||
|
|
@ -64,7 +64,24 @@ function experienceLabel(min, max) {
|
|||
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) {
|
||||
const map = recruiterMap(row)
|
||||
if (map) return Object.keys(map).map(String)
|
||||
const ids = Array.isArray(row?.current_recruiter_ids)
|
||||
? row.current_recruiter_ids.filter(Boolean).map(String)
|
||||
: []
|
||||
|
|
@ -73,6 +90,8 @@ function recruiterIdsFrom(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) {
|
||||
return row.recruiter_names.filter(Boolean)
|
||||
}
|
||||
|
|
@ -82,10 +101,24 @@ function recruiterNamesFrom(row) {
|
|||
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. */
|
||||
export function toJobView(row) {
|
||||
const recruiterIds = recruiterIdsFrom(row)
|
||||
const recruiterNames = recruiterNamesFrom(row)
|
||||
const manager = hiringManagerFrom(row)
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
|
|
@ -101,8 +134,8 @@ export function toJobView(row) {
|
|||
recruiterId: recruiterIds[0] || null,
|
||||
recruiterIds,
|
||||
recruiterNames,
|
||||
hiringManager: row.hiring_manager_name,
|
||||
hiringManagerId: row.hiring_manager_id,
|
||||
hiringManager: manager.name,
|
||||
hiringManagerId: manager.id,
|
||||
createdByName: row.created_by_name,
|
||||
applicantCount: row.applicant_count ?? 0,
|
||||
// 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. */
|
||||
export function listStatusHistory(jobPostId) {
|
||||
return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } })
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export const qk = {
|
|||
list: (p = {}) => ['jobs', 'list', p],
|
||||
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
|
||||
statusHistory: (id) => ['jobs', 'status-history', id],
|
||||
profile: (id, p = {}) => ['jobs', 'profile', id, p],
|
||||
stats: (p = {}) => ['jobs', 'stats', p],
|
||||
},
|
||||
talent: {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' }
|
|||
const PAGE_SIZE_MAX = 100
|
||||
|
||||
/** 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
|
||||
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. */
|
||||
function MiniRing({ score, size = 46 }) {
|
||||
export function MiniRing({ score, size = 46 }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -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="Couldn’t 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ import AiFieldAssist from '../ui/AiFieldAssist'
|
|||
import DataTable from '../ui/DataTable'
|
||||
import Modal from '../ui/Modal'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
|
|
@ -25,7 +24,6 @@ import { friendlyAuthError } from '../lib/errors'
|
|||
import { platformLabel } from '../lib/platforms'
|
||||
import * as jobsApi from '../api/jobs'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as assignmentsApi from '../api/assignments'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
import * as usersApi from '../api/users'
|
||||
import * as requisitionsApi from '../api/requisitions'
|
||||
|
|
@ -44,10 +42,10 @@ async function fetchJobs() {
|
|||
}
|
||||
|
||||
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) || '—'
|
||||
}
|
||||
|
||||
|
|
@ -108,31 +106,27 @@ export default function Jobs() {
|
|||
const [status, setStatus] = useState('')
|
||||
const [type, setType] = useState('')
|
||||
|
||||
const [viewing, setViewing] = useState(null)
|
||||
const [viewingTab, setViewingTab] = useState('details')
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
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
|
||||
// manager portal. Consume once and replace history: jobs refetch after a
|
||||
// status PATCH used to replay openCreate and pop the create modal over the
|
||||
// detail view. `/jobs?job=` / `?tab=history` is the notification target.
|
||||
// status PATCH used to replay openCreate and pop the create modal. A job
|
||||
// target (`/jobs?job=` / `?tab=history`, or state.openJob) now redirects to
|
||||
// the full job profile page at /job/:jobId.
|
||||
useEffect(() => {
|
||||
const st = location.state
|
||||
const jobId = searchParams.get('job') || st?.openJob
|
||||
const tab = String(searchParams.get('tab') || '').toLowerCase()
|
||||
if (!st?.openCreate && !jobId) return
|
||||
if (st?.openCreate) setCreating(true)
|
||||
if (jobId) {
|
||||
const job = jobs.find((j) => j.id === jobId)
|
||||
if (job) {
|
||||
setViewing(job)
|
||||
setViewingTab(tab === 'history' ? 'history' : 'details')
|
||||
} else if (!jobsQuery.isSuccess) return
|
||||
navigate(`/job/${jobId}${tab === 'history' ? '?tab=history' : ''}`, { replace: true })
|
||||
return
|
||||
}
|
||||
if (st?.openCreate) setCreating(true)
|
||||
const next = new URLSearchParams(searchParams)
|
||||
let queryChanged = false
|
||||
if (next.has('job')) {
|
||||
|
|
@ -144,15 +138,8 @@ export default function Jobs() {
|
|||
queryChanged = true
|
||||
}
|
||||
if (queryChanged) setSearchParams(next, { replace: true })
|
||||
if (st?.openJob || st?.openCreate) navigate('.', { replace: true, state: null })
|
||||
}, [location.state, searchParams, jobs, jobsQuery.isSuccess, 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
|
||||
if (st?.openCreate) navigate('.', { replace: true, state: null })
|
||||
}, [location.state, searchParams, navigate, setSearchParams])
|
||||
|
||||
const createJob = useMutation({
|
||||
mutationFn: async ({ payload, imageFile }) => {
|
||||
|
|
@ -211,17 +198,6 @@ export default function Jobs() {
|
|||
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(
|
||||
() => [...new Set(jobs.map(deptValue).filter(Boolean))].sort(),
|
||||
[jobs],
|
||||
|
|
@ -293,7 +269,7 @@ export default function Jobs() {
|
|||
key: '_a', label: 'Actions', align: 'right',
|
||||
render: (j) => (
|
||||
<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 && (
|
||||
<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}
|
||||
pageSize={50}
|
||||
empty="No requisitions match these filters."
|
||||
onRowClick={(j) => setViewing(j)}
|
||||
onRowClick={(j) => openJob(j.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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 && (
|
||||
<EditJobForm
|
||||
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,
|
||||
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 recruitersQuery = useRecruiterDirectory()
|
||||
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
|
||||
|
|
@ -1323,7 +1279,7 @@ function EditJobForm({ job: j, busy, onClose, onSubmit }) {
|
|||
* Hiring-manager + recruiter pointers on one requisition.
|
||||
* 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 qc = useQueryClient()
|
||||
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 statusRows = statusQuery.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
|
||||
object URL, because a bare <img src> cannot carry auth headers. null (404)
|
||||
simply renders nothing. */
|
||||
function JobCover({ jobId }) {
|
||||
export function JobCover({ jobId }) {
|
||||
const [url, setUrl] = useState(null)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
|
|
@ -1539,136 +1495,3 @@ function JobCover({ jobId }) {
|
|||
if (!url) return null
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { Link } from 'react-router-dom'
|
||||
|
|
|
|||
|
|
@ -1422,6 +1422,30 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.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); }
|
||||
|
||||
/* 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
|
||||
location controls hold a readable fixed width. The widths live here, not
|
||||
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) {
|
||||
.g-kpi-7 { grid-template-columns: 1fr; }
|
||||
.cand-page-actions { width: 100%; }
|
||||
.job-hero-actions { width: 100%; margin-left: 0; }
|
||||
.cand-page-actions .btn { flex: 1 1 auto; justify-content: center; }
|
||||
.hf-sign-grid { grid-template-columns: 1fr; }
|
||||
.hf-summary { grid-template-columns: repeat(2, 1fr); }
|
||||
|
|
|
|||
Loading…
Reference in New Issue