diff --git a/backend/alembic_setup.py b/backend/alembic_setup.py index 61f1581..838e0bd 100644 --- a/backend/alembic_setup.py +++ b/backend/alembic_setup.py @@ -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() diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 871fd89..ff9df68 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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.""" diff --git a/backend/job/app.py b/backend/job/app.py index 64c4042..64bd20d 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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), diff --git a/backend/job/assignment/views.py b/backend/job/assignment/views.py index 454d29a..3676ee3 100644 --- a/backend/job/assignment/views.py +++ b/backend/job/assignment/views.py @@ -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", } diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index e1d56de..ed8bc7a 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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 diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 0800ac4..0853131 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -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()) diff --git a/backend/job/job_post/plugins.py b/backend/job/job_post/plugins.py index 8604722..9a688b1 100644 --- a/backend/job/job_post/plugins.py +++ b/backend/job/job_post/plugins.py @@ -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 {}, + } diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index f0e1785..aae3e9a 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -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, + } diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 1ecc62f..6a378ff 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -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,32 +403,81 @@ 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( - row, - names=names, - hiring_manager_name=names.get(str(row.hiring_manager_id)), + 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, + 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 department=await Department.get_by_id(self.session,department_id) diff --git a/backend/migrations/seed/rbac_ingest.sql b/backend/migrations/seed/rbac_ingest.sql new file mode 100644 index 0000000..a1c849c --- /dev/null +++ b/backend/migrations/seed/rbac_ingest.sql @@ -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; diff --git a/backend/notifications/views.py b/backend/notifications/views.py index 0680586..001734a 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -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: diff --git a/backend/role/plugins.py b/backend/role/plugins.py new file mode 100644 index 0000000..42b2b6d --- /dev/null +++ b/backend/role/plugins.py @@ -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 diff --git a/backend/tests/test_job_profile_plugins.py b/backend/tests/test_job_profile_plugins.py new file mode 100644 index 0000000..4ead684 --- /dev/null +++ b/backend/tests/test_job_profile_plugins.py @@ -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}, + } diff --git a/backend/users/models.py b/backend/users/models.py index 2dc47ce..b182db1 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -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.""" diff --git a/frontend/job-profile.test.mjs b/frontend/job-profile.test.mjs new file mode 100644 index 0000000..81cae51 --- /dev/null +++ b/frontend/job-profile.test.mjs @@ -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('
', { + 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) diff --git a/frontend/package.json b/frontend/package.json index 2bd2e46..9429388 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4485be8..0ba5253 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -55,6 +55,7 @@ function LegacyCandidateRedirect() { const to = `/candidate/${encodeURIComponent(userId)}` return{j.description}
+{c.summary || No summary}
+ +{j.description}
-