Compare commits

...

33 Commits

Author SHA1 Message Date
ahmed.mujtaba 1493af9fea Merge pull request 'verification_check' (#96) from Verification_check_Form into dev_main
Deploy to S3 / deploy (push) Successful in 34s Details
Reviewed-on: #96
2026-09-16 11:06:15 +00:00
ahmed.mujtaba a023562110 Merge branch 'dev_main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Verification_check_Form 2026-09-16 16:05:38 +05:00
ahmed.mujtaba 3095fb338f verification_check 2026-09-16 16:04:36 +05:00
ahmed.mujtaba 626eb56942 Merge pull request 'added deparmtent' (#95) from Interview_Delay into dev_main
Deploy to S3 / deploy (push) Successful in 38s Details
Reviewed-on: #95
2026-09-16 09:15:46 +00:00
ahmed.mujtaba 6bbf33346f added deparmtent 2026-09-16 14:14:45 +05:00
ahmed.mujtaba 3838634e82 commit with reqxdepoart realtion
Deploy to S3 / deploy (push) Successful in 33s Details
2026-09-16 13:39:48 +05:00
ahmed.mujtaba b841677bc3 Users
Deploy to S3 / deploy (push) Successful in 42s Details
Department import correct
2026-09-15 18:41:08 +05:00
ahmed.mujtaba 7d185c3ff3 import department.models # noqa: F401
Deploy to S3 / deploy (push) Successful in 35s Details
correctede the sync crash
2026-09-15 18:19:58 +05:00
ahmed.mujtaba cf9cf7ca8e Merge pull request 'candidates are arrigving' (#94) from Edit_Job_Profile into dev_main
Deploy to S3 / deploy (push) Successful in 39s Details
Reviewed-on: #94
2026-09-15 13:04:40 +00:00
ahmed.mujtaba 09635eb679 Merge branch 'dev_main' into Edit_Job_Profile 2026-09-15 13:04:31 +00:00
ahmed.mujtaba c233d23c29 . 2026-09-15 18:02:14 +05:00
ahmed.mujtaba eaec58bd0e . 2026-09-15 17:36:17 +05:00
ahmed.mujtaba a2932fd1bf . 2026-09-15 17:30:59 +05:00
ahmed.mujtaba 43852e22e0 . 2026-09-15 15:52:53 +05:00
ahmed.mujtaba 1fa25f2854 , 2026-09-15 14:13:58 +05:00
yaseen.zafar de472a8155 update gitea worflows
Deploy to S3 / deploy (push) Successful in 41s Details
2026-09-14 23:10:30 +05:00
yaseen.zafar 4fed17850a update gitea worflows
Deploy to S3 / deploy (push) Successful in 37s Details
2026-09-14 22:16:16 +05:00
yaseen.zafar 101b1a18ce update gitea worflows
Deploy to S3 / deploy (push) Successful in 39s Details
2026-09-14 22:14:40 +05:00
yaseen.zafar e3914c3c58 update gitea worflows
Deploy to S3 / deploy (push) Successful in 39s Details
2026-09-14 22:00:39 +05:00
yaseen.zafar bebe87e1b7 update gitea worflows
Deploy to S3 / deploy (push) Failing after 11s Details
2026-09-14 21:59:38 +05:00
ahmed.mujtaba 51bd8b56f2 Merge branch 'dev_main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Edit_Job_Profile 2026-09-14 21:05:36 +05:00
ahmed.mujtaba 91bc1089b2 candidates are arrigving 2026-09-14 21:02:15 +05:00
ahmed.mujtaba 1c4a6696cc Merge pull request 'Department_Module' (#93) from Department_Module into dev_main
Deploy to S3 / deploy (push) Successful in 37s Details
Reviewed-on: #93
2026-09-14 14:22:25 +00:00
ahmed.mujtaba 377be6d786 Merge branch 'dev_main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Department_Module 2026-09-14 19:19:51 +05:00
ahmed.mujtaba 433b2086b7 tested department 2026-09-14 19:12:02 +05:00
ahmed.mujtaba 3c0421c2ec job linkked successfully 2026-09-14 18:21:23 +05:00
yaseen.zafar cf605ee3d6 added deployment scripts and its config
Deploy to S3 / deploy (push) Successful in 33s Details
2026-09-14 18:21:13 +05:00
ahmed.mujtaba f86c908076 job post and deaprtrment li kage 2026-09-14 18:13:27 +05:00
ahmed.mujtaba eee2071ac2 deapratment page and form working now linking depends 2026-09-14 17:57:09 +05:00
ahmed.mujtaba addbc77d2b . 2026-09-14 14:55:01 +05:00
ahmed.mujtaba ff1f91973d Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Department_Module 2026-09-14 13:53:46 +05:00
ahmed.mujtaba df684c421b . 2026-09-14 13:52:23 +05:00
ahmed.mujtaba 3e11d2812e . 2026-09-14 13:51:07 +05:00
68 changed files with 6121 additions and 683 deletions

View File

@ -3,7 +3,7 @@ name: Deploy to S3
on:
push:
branches:
- main
- dev_main
jobs:
deploy:
@ -24,7 +24,7 @@ jobs:
run: |
apt-get update -y
apt-get install -y zip
zip -r utopia-ai-hr-ats-portal.zip . \
zip -r utopia-ai-hr-ats-portal-dev.zip . \
-x ".git/*" \
-x ".gitea/*" \
-x ".gitignore/*" \
@ -46,7 +46,10 @@ jobs:
AWS_DEFAULT_REGION: us-east-1
run: |
echo "Uploading repo contents to S3..."
aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip
aws s3 cp utopia-ai-hr-ats-portal-dev.zip s3://utopia-ai-s3-bucket-2/utopia-ai-hr-ats-portal-dev.zip --region us-west-1

2
.gitignore vendored
View File

@ -93,4 +93,6 @@ frontend/dist/index.html
frontend/dist/**
nginx.conf
smoke.test.mjs
**.docx
**.docs
Annex**

Binary file not shown.

Binary file not shown.

835
RBAC_CONTEXT_PROMPT.md Normal file
View File

@ -0,0 +1,835 @@
# RBAC Context Prompt — HR-ATS-Portal (TalentFlow)
> Paste this whole file as context for an engineer or LLM that must reproduce this system's
> role-based access control exactly. Everything below is taken from the code on branch
> `Department_Module`. File references point back to the source of truth.
> Where the code has a quirk or gap, it is written down as-is under **Known behaviour to
> reproduce (or consciously fix)**. Do not tidy these away silently.
---
## 0. Your task
You are implementing an access-control layer that must behave **identically** to the one
described here. That means the same:
- data model (tags → bundles → roles → users),
- permission vocabulary (136 `module.action` tags),
- resolution algorithm (live, per request, deny-by-default),
- enforcement order and HTTP status codes and error strings,
- rules against privilege escalation,
- row-level data scoping (who sees which jobs, candidates, offers, requisitions),
- role-name-based business rules (tasks, assignments, hiring-manager portal),
- frontend gating (routes, sidebar, buttons) and the Access Control matrix editor.
When this document and your instincts disagree, follow this document.
---
## 1. Core concepts in one paragraph
A **permission tag** is one atomic `module.action` string, such as `candidates.view`. A
**permission bundle** (table `permissions`) is a named JSONB array of tag ids. A **role** is a
named JSONB array of bundle ids. A **user** has zero or one role (`users.role_id`, nullable).
On every authenticated request the server walks role → bundles → tags and builds a flat list of
tag names. Route guards check that list. Service code then narrows which rows are visible
using a few helper predicates, some of which look at tags and some at the role name. Tags
never go into the JWT, so a permission change takes effect on the server on the very next
request.
---
## 2. Data model
PostgreSQL, schema `app`. SQLModel/SQLAlchemy async. Every table soft-deletes
(`is_deleted`) and has an `is_active` flag.
### 2.1 `permission_tags` — [backend/role/models.py](backend/role/models.py)
| column | type | notes |
|---|---|---|
| `id` | int PK | seed order matters: it sets matrix ordering and resolution ordering |
| `tag_name` | varchar(64) unique, indexed | `"{module}.{action}"` |
| `module` | varchar(32) indexed | |
| `action` | varchar(32) | |
| `description` | text null | |
| `is_active` / `is_deleted` | bool | |
| `created_at` / `updated_at` | timestamptz | |
Unique constraint `uq_permission_tags_module_action` on (`module`, `action`).
### 2.2 `permissions` (bundles)
| column | type | notes |
|---|---|---|
| `id` | int PK | |
| `name` | varchar(64) unique | |
| `description` | text null | |
| `permission_tags` | JSONB int[] | ids from `permission_tags.id`, **no FK** |
| `is_system` | bool | system bundles cannot be renamed |
| `is_active` / `is_deleted` / timestamps | | |
### 2.3 `roles`
| column | type | notes |
|---|---|---|
| `id` | int PK | |
| `role_name` | varchar(64) unique | free text (initial Alembic revision used an enum; the model is a varchar) |
| `description` | text null | |
| `permissions` | JSONB int[] | ids from `permissions.id`, **no FK** |
| `is_system` | bool | system roles cannot be renamed or deleted |
| `is_active` / `is_deleted` / timestamps | | |
### 2.4 `users` — [backend/users/models.py](backend/users/models.py)
Only the RBAC-relevant columns:
| column | type | notes |
|---|---|---|
| `id` | uuid PK | the JWT `sub` |
| `email` | unique | |
| `role_id` | int FK → `roles.id`, **nullable** | `role` relationship is `lazy="selectin"` |
| `is_active` | bool, default **false** | set true by email confirmation |
| `is_approved` | bool, default **false** | set true by an admin (or at admin creation) |
| `is_deleted` | bool | |
### 2.5 System role keys — `EnumRoles`
```
system_administrator hr_administrator recruiter hiring_manager
department_head interviewer ceo candidate
```
Seed ids follow that order: 1 system_administrator, 2 hr_administrator, 3 recruiter,
4 hiring_manager, … , 8 candidate. **Some code hardcodes ids 4 and 8** (see §12).
Migration `026` soft-deletes `hr_administrator`, `interviewer` and `ceo` when no live user
holds them. The organisation runs four staff roles (`system_administrator`, `recruiter`,
`hiring_manager`, `department_head`) plus `candidate`. Migration `027` moves members of a
hand-made role named `Manager` onto `hiring_manager` and soft-deletes it. Code still accepts the
names `manager` and `admin` (see §6).
---
## 3. Permission vocabulary — 17 modules × 8 actions = 136 tags
Source: `PermissionModule`, `PermissionAction`, `PermissionTag` in
[backend/users/permissions.py](backend/users/permissions.py).
**Modules:** `dashboard, inbox, jobs, candidates, pipeline, department, interviews,
assessments, offers, reports, analytics, job_board, settings, rbac_users, tasks, talent,
requisitions`
**Actions:** `view, create, edit, delete, approve, export, manage, configure`
Rules:
1. `PermissionTag` is a `str` Enum listing every combination explicitly. At import time,
`_assert_vocabulary_complete()` raises `RuntimeError("PermissionTag vocabulary drift:
missing=[...] extra=[...]")` unless the enum equals the full modules × actions cross-product.
The server will not boot with a partial vocabulary.
2. Always serialise with `.value`. `f"{PermissionTag.X}"` renders the enum repr.
3. DB rows are seeded idempotently (`ON CONFLICT (tag_name) DO NOTHING`) by manual SQL
migrations: `001` (first 13 modules = 104 tags), `004` tasks, `007` talent,
`019` requisitions, `038` department. Comments in the code that say "104" or "120" tags are
stale. The real total is 136.
4. Two actions carry special meaning beyond "may use the screen":
- `*.manage` on `requisitions`, `candidates` and `offers` **removes row scoping**. See §6.
- `requisitions.configure` is an **opt-in to scoping**, not a screen permission. See §6.
---
## 4. Resolution algorithm — `Roles.resolve_tags(session, role)`
```
if role is None or not role.is_active or role.is_deleted: return ()
perm_ids = role.permissions
if not perm_ids or not a list: return ()
bundles = SELECT permissions WHERE id IN perm_ids AND is_active AND NOT is_deleted
tag_ids = concat(bundle.permission_tags for each bundle whose permission_tags is a non-empty list)
if not tag_ids: return ()
tags = SELECT permission_tags WHERE id IN tag_ids AND is_active AND NOT is_deleted
sort tags by (id, tag_name); de-duplicate by tag_name keeping first
return tuple(tag_name ...)
```
Properties you must preserve:
- **Deny by default, never error.** Dangling ids, inactive or deleted bundles, and inactive or
deleted tags simply add nothing.
- **Union.** Tags from every attached bundle are merged. There are no negative grants.
- **Live.** It runs on every request inside `get_current_user`. Nothing is cached server-side
and nothing is put in the token.
- **Stable order.** The output is ordered by tag id, which is seed order.
---
## 5. Authentication and enforcement pipeline
### 5.1 Tokens — [backend/users/plugins.py](backend/users/plugins.py)
PyJWT HS256. Every token carries `sub`, `type`, `iat`, `exp`, `jti`.
`decode_token(token, expected_type=...)` rejects a token whose `type` does not match.
| type | default lifetime | extra claims |
|---|---|---|
| `access` | 30 min | `email`, `role_id` |
| `refresh` | 7 days | — |
| `reset` | 10 min | `crid` |
The token's `role_id` is informational only. Authorization always reloads the user from the DB.
There is no logout endpoint, no denylist and no `jti` tracking.
Login, signup, refresh and `/users/create` return:
`{access_token, refresh_token, token_type:"bearer", expires_in, data: <user without permissions>, status_code}`.
### 5.2 `get_current_user` (dependency alias `CurrentUser`)
The checks run in this order:
1. HTTP Bearer header is missing → FastAPI `HTTPBearer` returns **401** `"Not authenticated"` (FastAPI 0.136.1 behaviour).
2. Decode fails or type is not `access`**401** `"Could not validate credentials"` with `WWW-Authenticate: Bearer`.
3. Load the user by `sub` via `Users.get_user_by_id`. That query **excludes `role_id = 8` (candidate)**.
If the user is missing, `is_deleted`, or `!is_active`**401** `"User is inactive or does not exist"`.
4. `!is_approved`**403** `"Your Approval is at Pending"`.
5. `permissions = resolve_tags(user.role)`.
6. Return `serialize_user(user, with_permissions=True, permissions=...)`:
```json
{ "id": "uuid", "name": "...", "email": "...", "role_id": 3, "role_name": "recruiter",
"role_description": "...", "linkedin_url": null, "is_active": true, "is_approved": true,
"is_deleted": false, "created_at": "...", "updated_at": "...",
"permissions": ["dashboard.view", "..."] }
```
`GET /users/me` returns exactly this and requires only `CurrentUser`, with no tag. That way a user
with no role can still discover their state.
### 5.3 `require_permission(*tags, require_all=True)`
A FastAPI dependency factory that runs after `get_current_user`:
1. `current_user.role_id is None`**403** `"User has no role assigned"`. This check runs before any tag check.
2. `has_permission(granted, *tags, require_all)`:
- `require_all=True` → required ⊆ granted (AND)
- `require_all=False` → required ∩ granted ≠ ∅ (OR)
3. On failure → **403** with one of these exact details:
- one tag, AND: `"Missing required permission: candidates.view"`
- several tags, AND: `"Missing required permissions: a, b"`
- OR: `"Missing any of required permissions: a, b"`
4. On success it returns `current_user`, and handlers use it as `current_user: dict`.
A user whose role is soft-deleted or inactive still has a `role_id`. They pass step 1, resolve
to zero tags, and fail step 3.
### 5.4 Login and account-state rules — [backend/users/views.py](backend/users/views.py)
- `authenticate_user`: the email lookup excludes role 8. A bad email or password returns **401**
`"Incorrect email or password"`. Then `is_deleted` → 401 `"User is inactive"`, then
`!is_active` → 401 `"Please confirm your email address to activate your account"`, then
`!is_approved` → 403 `"Your Approval is at Pending"`.
- `refresh_access_token`: runs the same active, deleted and approved checks, with 401
`"Invalid or expired refresh token"` on a decode failure.
- **Signup** (`POST /users/signup`, public): hardcodes `role_id = 4` and `is_approved = false`,
lands with `is_active = false`, and emails a confirmation link that sets `is_active`.
An admin must then approve the account.
- **Admin create** (`POST /users/create`, needs `rbac_users.create`): sets `is_approved = true`.
`is_active` comes from the payload (default true). The escalation check in §5.5 applies.
- **Approval queue**: `GET /users/pending-approvals` (needs `settings.view`) lists users who are
active, not approved, not deleted and not role 8. `PUT /users/approve?record_id=` (needs
`rbac_users.edit`) returns 400 when the user is not active: `"User must confirm their email
before approval"`.
- **Candidate accounts (role 8)** cannot log in or resolve through `get_current_user`. They are
data records for applicants, not portal users.
### 5.5 Anti-escalation on role assignment — `User._check_role_assignment`
Used by `POST /users/create`, `PUT /users/assign-role` and `PUT /users/remove-role`. Both
assign and remove are also route-guarded by `rbac_users.edit`.
```
if new_role_id == existing_role_id: return # no-op, no checks
if 'rbac_users.manage' not in caller.perms: 403 "Assigning a role requires rbac_users.manage"
if new_role_id is None: return # removal needs only manage
role = roles[new_role_id]
if role missing or is_deleted: 404 "Role not found"
if not role.is_active: 400 "Role is not active"
missing = resolve_tags(role) - caller.perms
if missing: 403 "Cannot assign a role with permissions you do not hold: a, b"
```
So a caller can only hand out a role whose effective tags are a subset of their own.
---
## 6. Row-level scoping (data visibility on top of tags)
Tags decide **whether** a user may call an endpoint. These predicates decide **which rows**
they get back. They are pure functions of the `current_user` dict.
Backend: [backend/users/permissions.py](backend/users/permissions.py). The frontend mirror is
in [frontend/src/auth/permissions.js](frontend/src/auth/permissions.js) and must stay
byte-for-byte equivalent in logic.
```python
def is_hiring_manager(u): # "hiring-manager portal" user
return lower(strip(u.role_name)) in {"hiring_manager", "manager"}
def is_admin(u): # org-wide staff
return lower(strip(u.role_name)) in {"system_administrator", "hr_administrator", "admin"} \
or "requisitions.manage" in u.permissions
def sees_all_candidates(u):
return is_admin(u) or "candidates.manage" in u.permissions
def sees_all_offers(u):
return is_admin(u) or "offers.manage" in u.permissions
def scopes_to_own_requisitions(u): # evaluate in this exact order
if is_hiring_manager(u): return True # wins even over admin tags
if is_admin(u) or sees_all_candidates(u): return False
return "requisitions.configure" in u.permissions
```
Design rule stated in the code: **custom roles must be able to opt in through Access Control
tags. Never key scoping off `role_id`.** Role-name checks exist only for the seeded
hiring-manager and admin identities.
### 6.1 Job ownership sets — [backend/job/job_post/models.py](backend/job/job_post/models.py)
- `JobPosts.ids_for_manager(user_id)` returns non-deleted job posts where
`hiring_manager_id = user`, **unioned with** jobs whose `requisition_id` points at a
non-deleted requisition with `created_by = user`.
- `JobPosts.ids_for_creator(user_id, created_by=False)`:
- `created_by=True` returns jobs with `created_by = user`.
- Otherwise it returns jobs where the user is in `current_recruiter_ids` or is
`current_recruiter_id`, **or** (the job has no recruiters **and** `created_by = user`).
### 6.2 `owned_job_ids_for_candidate_scope(session, user, created_by=False)` — [backend/job/candidate/views.py](backend/job/candidate/views.py)
```
if scopes_to_own_requisitions(user): return ids_for_manager(user.id)
if sees_all_candidates(user): return None # None = unscoped
return ids_for_creator(user.id, created_by)
```
`job_post_ids_for_candidate_list` intersects a caller-requested job filter with that set.
`None` means unscoped and `[]` means nothing is visible.
### 6.3 `assert_manager_candidate_access(session, user, user_id|job_post_id|inbox_id|manual_id)`
```
if sees_all_candidates(user) and not scopes_to_own_requisitions(user): allow
owned = owned_job_ids_for_candidate_scope(...) or []
if not owned: 403 <scope detail>
resolve job_id (and candidate uid) from inbox_id / manual_id when not given
if job_id is None and uid is not None:
allow if any job the candidate is assigned to ∈ owned, else 403
if job_id not in owned: 403 <scope detail>
```
The scope detail is `MANAGER_SCOPE_DETAIL` when requisition-scoped and `CREATOR_SCOPE_DETAIL` otherwise.
### 6.4 Where scoping is applied
| Area | Rule |
|---|---|
| Candidates list / detail / applications / notes / forms | `owned_job_ids_for_candidate_scope` + `assert_manager_candidate_access` |
| `GET /candidate/fetch/users` and `/count` | Hiring-manager users get **403** `"Hiring managers can only list candidates on their requisitions"` |
| Candidate detail without `user_id` | Hiring manager → 403 `MANAGER_SCOPE_DETAIL` |
| Job posts list (`fetch_job_posts`) | If `scopes_to_own_requisitions`, restrict to `ids_for_manager`. Requested ids outside that set are dropped, and an empty set returns `[]` |
| Offers (candidate picker, create, sent) | `sees_all_offers` → unscoped. Otherwise use owned job ids, and an out-of-scope job returns 403 `"This offer is outside your assigned jobs"` |
| Requisition forms (`get_form_by_id`) | `is_admin` → all rows. Otherwise `created_by = me` |
| Candidate hiring forms list | Hiring manager with no `form_id`, `inbox_id`, `manual_upload_candidate_id` or `job_post_id` → 403 `"Hiring managers can only load forms for candidates on their requisitions"`. Otherwise `assert_manager_candidate_access` |
---
## 7. Role-name business rules (not tag-driven)
These rules look up role **names** and resolve ids from the `roles` table at request time.
| Rule | Where | Behaviour |
|---|---|---|
| Task creators | [backend/tasks/views.py](backend/tasks/views.py) | Route requires `tasks.create` **and** the caller's `role_id` must be one of the ids for `system_administrator`, `hr_administrator` or `recruiter`, else 403 `"Only system administrators, HR administrators and recruiters can create tasks"` |
| Task assignee | tasks | Must be an existing, non-deleted user with role `recruiter`, else 422 `"Tasks can only be assigned to recruiter accounts"`. Omitting the assignee is allowed only when the caller is a recruiter, who then self-assigns |
| Task assignee picker | `GET /tasks/assignees/fetch` (`tasks.view`) | All `recruiter` users, so the caller does not need `rbac_users.view` |
| Job assignment roles | [backend/job/assignment/views.py](backend/job/assignment/views.py) | `primary_recruiter` → user must hold `recruiter`; `hiring_manager` → user must hold `hiring_manager`. Otherwise 422 `"{field} must be a {role}"`. The user must also be active and not deleted |
| Application assignment | same | Assignee must be `recruiter` |
| Job post recruiters / HM | [backend/job/job_post/views.py](backend/job/job_post/views.py) | `current_recruiter_ids` must be recruiters; `hiring_manager_id` must be a hiring_manager |
| Inbox assign-recruiter | [backend/inbox/views.py](backend/inbox/views.py) | `recruiter_id` must be a `recruiter`, else 422 |
| Hiring-manager directory | `GET /managers/fetch` (`jobs.view OR candidates.view OR job_board.create`) | Users with role `hiring_manager`. Returns 500 if that role is not seeded |
| Recruiter performance | analytics | Iterates users whose role is `recruiter` |
| Admin notifications | [backend/notifications/views.py](backend/notifications/views.py) | Recipients are users with role `system_administrator` |
| Candidate identity | inbox / candidate / search models | Applicants are users with role `candidate` |
---
## 8. Seeded bundles and who gets them
The **initial** roles and the original bundle set, including `all_access` for
`system_administrator` (a fixed id list), were seeded outside this repo. Do not assume their
contents; export them (see §10). The manual migrations below are in the repo and all run
idempotently at startup via `alembic_setup.run_manual_sql()`.
| Bundle (`is_system=true`) | Tags | Attached to |
|---|---|---|
| `analytics_dashboard` (001) | all `dashboard.*`, `analytics.*`, `offers.*` + `interviews.view` | sysadmin, hr_admin, recruiter, hiring_manager, department_head, ceo |
| `tasks_management` (004) | all `tasks.*` | sysadmin, hr_admin, recruiter (005 removed it from hiring_manager, department_head, ceo) |
| `tasks_viewer` (005) | `tasks.view`, `tasks.export` | hiring_manager, department_head, ceo |
| `talent_sourcing` (007) | all `talent.*` | the six staff roles |
| `hiring_forms` (008) | `interviews.create`, `interviews.edit`, `interviews.delete` | the six staff roles |
| `requisitions_management` (019) | all `requisitions.*` (**includes `.manage` and `.configure`**) | the six staff roles |
| `manager_candidates` (024/025) | `candidates.view`, `candidates.create`, `candidates.edit` | hiring_manager (and a legacy `manager` role) |
| `requisitions_self` (028) | `requisitions.view`, `requisitions.create`, `requisitions.edit` | none. Meant for custom roles |
| `interviews_tab` (028) | `interviews.view`, `interviews.create`, `interviews.edit` | none. Meant for custom roles |
| `department_management` (038) | all `department.*` | sysadmin, hr_admin |
"The six staff roles" means `system_administrator, hr_administrator, recruiter, hiring_manager,
department_head, ceo`.
Pattern for adding a module (copy it exactly):
1. Add the module to `PermissionModule` **and** all 8 `PermissionTag` members (the startup assertion enforces this).
2. Add the module to `MODULES` in `frontend/src/auth/permissions.js`.
3. Write a manual SQL migration that inserts the 8 tags (`ON CONFLICT DO NOTHING`), creates a
`<module>_management` bundle with `jsonb_agg(id ORDER BY id)` over that module, and
appends the bundle id to the chosen roles guarded by
`NOT (permissions @> jsonb_build_array(id))`.
4. Guard the routes with `require_permission(PermissionTag.<MODULE>_<ACTION>)`.
5. Add the route to `frontend/src/app/routes.js` with `permission: '<module>.view'`.
6. Users must re-fetch `/users/me` (log in again) before the UI reflects the change.
**Consequence of the seed, if nobody has edited the matrix:** `requisitions_management` gives
`requisitions.manage` to recruiter, hiring_manager and department_head. `is_admin()` is
therefore true for recruiter and department_head, so they see every candidate, offer and
requisition. Hiring managers stay scoped only because `is_hiring_manager` is checked first in
`scopes_to_own_requisitions`. Verify this against the live export before relying on it.
---
## 9. Access Control editing (how grants change at runtime)
### 9.1 Endpoints — [backend/role/app.py](backend/role/app.py), [backend/role/views.py](backend/role/views.py)
| Method | Path | Tag | Behaviour |
|---|---|---|---|
| GET | `/roles/fetch[?record_id]` | `rbac_users.view` | Each role is expanded to `{..., permissions:[bundle ids], bundles:[bundle payloads with tag_names], effective_permissions:[resolved tag names]}` |
| POST | `/roles/create` | `rbac_users.create` | `role_name` required (400); duplicate → 409 `"Role name already exists"`; always `is_system=false` |
| PUT | `/roles/update?record_id` | `rbac_users.edit` | Partial update. Renaming a system role → 409 `"System roles cannot be renamed"`. May replace `permissions` (bundle ids) |
| DELETE | `/roles/delete?record_id` | `rbac_users.delete` | Soft delete. System role → 409 `"System roles cannot be deleted"` |
| PUT | `/roles/matrix/update?record_id` | `rbac_users.edit` | **Matrix save**, see §9.2 |
| GET | `/permissions/fetch` | `rbac_users.view` | Bundles with `tag_names` |
| POST | `/permissions/create` | `rbac_users.manage` | Always `is_system=false`; name clash → 409 |
| PUT | `/permissions/update?record_id` | `rbac_users.manage` | Renaming a system bundle → 409 |
| PUT | `/roles/permission-tags/update` | `rbac_users.manage` | Body `{id: bundleId, permission_tags:[...], name?, description?, is_active?}` sets the exact tag set on one shared bundle. Unknown or inactive tag ids → 422 `"Unknown or inactive permission tag ids: [...]"`. Every role holding the bundle is affected immediately |
| GET | `/permission-tags/fetch` | `rbac_users.view` | Ordered by module, action |
All list endpoints accept `search`, `top`, `skip` and return `{data, total, status_code}`.
404s: `"Role not found"`, `"Permission bundle not found"`, `"Permission tag not found"`.
### 9.2 Matrix save — `Role.set_role_matrix(role_id, tag_ids)`
```
role must exist and not be deleted (404)
tag_ids = sorted(unique(tag_ids))
unknown or inactive ids → 422 "Unknown or inactive permission tag ids: [...]"
overlay = bundle named f"role_{role.id}_matrix"
if overlay is missing: create it (is_system=false, description "Access Control matrix for {role_name}")
else: overwrite its permission_tags
role.permissions = [overlay.id] # REPLACES every other bundle on the role
return the role payload
```
Shared system bundles are never mutated by the matrix. After the first save, a role's grant is
exactly the ticked cells. The seeded bundles no longer apply to that role, even though they
still exist.
### 9.3 Access Control screen — [frontend/src/screens/Rbac.jsx](frontend/src/screens/Rbac.jsx)
- Route `rbac`, gated on `rbac_users.view`.
- The role list hides `role_name === 'candidate'`. System roles show a "System role" badge and
have no delete action.
- The matrix has rows = modules and columns = actions. Both are derived from `/permission-tags/fetch`
in first-seen (id) order. A cell renders only if that tag exists; otherwise it shows `·`.
- The draft starts from `role.effective_permissions`. Toggles are disabled without
`rbac_users.edit`. Save is enabled only when the draft differs; it maps names to ids and
calls `PUT /roles/matrix/update`.
- Tooltip help: `requisitions.configure` = "Limit Jobs and Candidates to requisitions this user
created. Independent of Create."; `candidates.manage` = "See every candidate, not only jobs
this user owns."; `requisitions.manage` = "Org-wide requisition list (admin)."
- The New/Edit Role form edits name, description, active flag and **bundle** picks, and shows a
live preview of the union of `tag_names` from the picked bundles.
Settings screen: the *Approvals* tab lists `/users/pending-approvals` and its Approve button
requires `rbac_users.edit`. The *Users* tab shows Pending / Awaiting approval / Active badges.
---
## 10. Export the live grants before mirroring
Seeds and matrix edits diverge over time. Take the effective role → tag map from the database
rather than from §8:
```sql
SELECT r.id, r.role_name, r.is_system, r.is_active, r.is_deleted,
string_agg(DISTINCT p.name, ', ') AS bundles,
count(DISTINCT t.id) AS tag_count,
string_agg(DISTINCT t.tag_name, ', ' ORDER BY t.tag_name) AS effective_tags
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_active AND NOT p.is_deleted
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_active AND NOT t.is_deleted
WHERE r.is_active AND NOT r.is_deleted
GROUP BY r.id
ORDER BY r.id;
```
Also dump `app.permissions` (id, name, is_system, permission_tags) and `app.permission_tags`
(id, tag_name) **with their ids**. Bundle and role arrays reference ids, so the ids must be
preserved.
---
## 11. Frontend mirror (cosmetic gating; the server is authoritative)
### 11.1 Session and permission bootstrap — [frontend/src/auth/AuthProvider.jsx](frontend/src/auth/AuthProvider.jsx)
- The session (tokens + `data`) lives in `lib/tokenStore`. `GET /users/me` runs as a TanStack
Query (`staleTime` 5 min, `retry: false`) whenever an access token exists. Its result is merged
into the stored session so a page reload already has `permissions`.
- `status` is `anonymous` (no token), `error` (me failed), `authenticated` (me data or
cached permissions present), or `loading`.
- `can(tag)` comes from `makeCan(permissions)`: **a null or undefined tag is always allowed**,
otherwise it is set membership.
- Sign-in invalidates the `me` query so the previous user's permissions cannot leak. Sign-out
only clears client state. When a refresh fails, the user is sent to `/auth/login?expired=1`.
### 11.2 Route guard — [frontend/src/auth/RequireAuth.jsx](frontend/src/auth/RequireAuth.jsx)
`anonymous` → redirect to `/auth/login` (keeping `from`); `error``/auth/login?expired=1`;
`loading` → full-page spinner, so the nav does not flash; a `permission` the user lacks →
`<Forbidden/>` ("You don't have access to this page").
### 11.3 Route table — [frontend/src/app/routes.js](frontend/src/app/routes.js)
| path | permission | | path | permission |
|---|---|---|---|---|
| dashboard | `dashboard.view` | | interviews | `interviews.view` |
| inbox | `inbox.view` | | requisitions | `requisitions.view` |
| matching (hidden) | `candidates.view` | | assessments | `assessments.view` |
| jobs | `jobs.view` | | offers | `offers.view` |
| candidates | `candidates.view` | | managers | `jobs.view` |
| cvbank | `candidates.view` | | departments | `department.view` |
| pipeline | `pipeline.view` | | calendar | `interviews.view` |
| progress | `jobs.view` | | reports | `reports.view` |
| import | `candidates.create` | | analytics | `analytics.view` |
| jobboard | `job_board.view` | | aistudio | *null* |
| recruiterhub | `analytics.view` | | notifications | *null* |
| talent | `talent.view` | | rbac | `rbac_users.view` |
| tasks | `tasks.view` | | settings | `settings.view` |
| aiassistant | *null* | | help | *null* |
Candidate detail sub-routes in `App.jsx` also require `candidates.view`.
### 11.4 Sidebar — [frontend/src/app/Sidebar.jsx](frontend/src/app/Sidebar.jsx)
A route is shown when `!hidden && can(permission) && (!isHiringManager(user) ||
HIRING_MANAGER_NAV.has(path))`, where
`HIRING_MANAGER_NAV = {candidates, requisitions, interviews, calendar, help, aiassistant, aistudio, notifications}`.
A group heading renders only if at least one of its items survives.
### 11.5 Hiring-manager portal behaviour
- `Dashboard` redirects hiring managers to `/candidates`.
- `Candidates` renders `<HiringManagerCandidates/>` for them; other users get the scoped or
unscoped list via `seesAllCandidates` / `scopesToOwnRequisitions`.
- The Favorite button on the candidate profile is hidden for hiring managers.
### 11.6 In-screen action gating (examples to mirror)
`jobs.edit` / `jobs.delete` on Jobs; `job_board.create` for Post Job; `pipeline.edit` to move a
stage; `candidates.create` for notes and ATS re-run; `candidates.edit` for rating, favorite and
matching assignment; `interviews.create || candidates.create` to schedule an interview;
`offers.create` / `offers.edit` on the offer form; `assessments.create|edit|delete`;
`reports.create|delete|export`; `requisitions.create|edit`; `department.create|edit`;
`inbox.edit`; `talent.edit`; `settings.configure`; `rbac_users.edit` for approvals;
`tasks.view|edit` on Recruiter Hub.
Tasks "create" requires `can('tasks.create') && ['system_administrator','hr_administrator','recruiter'].includes(user.role_name)`.
---
## 12. Known behaviour to reproduce (or consciously fix)
Mirror these exactly unless you have been told to change them, and record any deviation.
1. **Unauthenticated data routes:** `GET /email/fetch` and `GET /inbox/fetch` have no auth
dependency. `GET /jobs/alias` is public.
2. **No escalation check on role or bundle edits:** a holder of `rbac_users.edit` can set any
tags on any role, **including their own**, through `/roles/update` or `/roles/matrix/update`.
A holder of `rbac_users.manage` can do the same through the bundle endpoints. Only user↔role
assignment is subset-checked (§5.5).
3. `POST /users/create` **without** `role_id` skips the `rbac_users.manage` check entirely.
4. Hardcoded ids: signup assigns `role_id = 4`. `Users.get_users`, `count_users`,
`get_user_by_id`, `get_user_by_email` and `get_pending_approvals` exclude `role_id = 8`.
Other code resolves roles by name.
5. Name-based identities are matched case-insensitively and include legacy aliases: `manager`
→ hiring manager, `admin` → admin.
6. `requisitions.manage` makes a user an **admin** for candidate, offer and requisition scoping,
not only for requisitions.
7. No token revocation. A refresh token stays valid for 7 days after sign-out. Permission
changes apply server-side on the next request, but the UI updates only after `/users/me`
is refetched (re-login).
8. Users on a deleted or inactive role can still log in, but they hold zero tags.
9. Route handlers wrap unexpected exceptions as `HTTPException(500, detail=str(e))`, and the
frontend may present any error as a permissions problem.
10. Comments in `frontend/src/auth/permissions.js` and `routes.js` saying enforcement is
"cosmetic, only /users/*, /roles/*, /permissions/* are enforced" are **stale**. As the
table below shows, almost every route is now guarded server-side.
11. `/email/sync` accepts either a JWT whose user holds `inbox.edit`, or a static
`CRON_INBOX_SYNC_TOKEN` compared in constant time, for the scheduler.
---
## 13. Acceptance checks for a faithful mirror
- The server refuses to boot if the tag enum is not the full modules × actions product.
- With no role → 403 `"User has no role assigned"` on any guarded route, while `/users/me` still returns 200.
- Deactivating a bundle removes its tags from every role on the very next request, with no re-login.
- Saving the matrix for role R creates or updates `role_R_matrix` and sets `R.permissions = [that id]`.
- Assigning a role that holds a tag the caller lacks → 403, and the message lists the missing tags.
- A hiring manager with every admin tag is still requisition-scoped.
- A custom role with `requisitions.create` alone is **not** scoped. Adding `requisitions.configure` scopes it; adding `candidates.manage` unscopes it.
- A recruiter without `candidates.manage` or `requisitions.manage` sees only jobs where they are a current recruiter, or which they created and which have no recruiters.
- Task creation by `department_head`, even when holding `tasks.create`, → 403.
- A frontend `can(null)` is true, and hiring managers see only the 8 locked nav items.
- The frontend predicate tests in [frontend/permissions-scope.test.mjs](frontend/permissions-scope.test.mjs) pass against your implementation.
---
## Appendix A — Every backend route and its guard
`AND` = `require_all=True`; `OR` = `require_all=False`. Row scoping (§6) and role-name rules
(§7) apply on top of these guards. Generated from the `@router` decorators in `backend/*/app.py`.
| Domain | Method | Path | Required |
|---|---|---|---|
| analytics | GET | `/analytics/kpis/fetch` | analytics.view |
| analytics | GET | `/analytics/funnel/fetch` | analytics.view |
| analytics | GET | `/analytics/hiring-trend/fetch` | analytics.view |
| analytics | GET | `/analytics/source-performance/fetch` | analytics.view |
| analytics | GET | `/analytics/applications-per-job/fetch` | analytics.view |
| analytics | POST | `/analytics/ask` | analytics.view |
| analytics | GET | `/analytics/recruiter-performance/fetch` | analytics.view |
| assessments | GET | `/assessments/fetch` | assessments.view |
| assessments | GET | `/assessments/counts` | assessments.view |
| assessments | POST | `/assessments/create` | assessments.create |
| assessments | PATCH | `/assessments/update` | assessments.edit |
| assessments | DELETE | `/assessments/delete` | assessments.delete |
| assessments | POST | `/assessments/remind` | assessments.edit |
| candidate_forms | GET | `/forms/requisition/search` | requisitions.view OR job_board.create OR jobs.create |
| candidate_forms | GET | `/forms/requisition/fetch` | requisitions.view |
| candidate_forms | POST | `/forms/requisition/create` | requisitions.create |
| candidate_forms | PATCH | `/forms/requisition/update` | requisitions.edit |
| candidate_forms | GET | `/forms/definitions` | interviews.view |
| candidate_forms | GET | `/forms/fetch` | interviews.view |
| candidate_forms | POST | `/forms/create` | interviews.create |
| candidate_forms | PATCH | `/forms/update` | interviews.edit |
| candidate_forms | DELETE | `/forms/delete` | interviews.delete |
| department | GET | `/department/fetch` | department.view |
| department | POST | `/department/create` | department.create |
| department | PUT | `/department/update` | department.edit |
| department | GET | `/department/heads/fetch` | department.create OR department.edit |
| forget_password | POST | `/users/forget-password` | PUBLIC |
| forget_password | POST | `/users/forget-password/verify-code` | PUBLIC |
| forget_password | POST | `/users/forget-password/new-password` | PUBLIC |
| g_sheet | GET | `/sheet/health` | PUBLIC |
| g_sheet | GET | `/sheet/metadata` | settings.view |
| g_sheet | GET | `/sheet/tabs` | settings.view |
| g_sheet | GET | `/sheet/fetch` | settings.view |
| g_sheet | POST | `/sheet/import` | settings.edit |
| g_sheet | POST | `/sheet/{tab}/import` | settings.edit |
| g_sheet | GET | `/sheet/import/fetch` | settings.view |
| g_sheet | GET | `/sheet/form-data/sheets` | inbox.view OR settings.view |
| g_sheet | GET | `/sheet/form-data/fetch` | inbox.view OR settings.view |
| g_sheet | GET | `/sheet/form-data/counts` | inbox.view OR settings.view |
| g_sheet | GET | `/sheet/form-data/count` | inbox.view OR settings.view |
| g_sheet | GET | `/sheet/form-data/{record_id}` | inbox.view OR settings.view |
| g_sheet | PATCH | `/sheet/form-data/{record_id}/assign-job-post` | inbox.edit OR settings.edit |
| g_sheet | PATCH | `/sheet/form-data/{record_id}/processing-state` | inbox.edit OR settings.edit |
| g_sheet | PATCH | `/sheet/form-data/{record_id}/duplicate` | inbox.edit OR settings.edit |
| g_sheet | DELETE | `/sheet/form-data/{tab}/delete` | settings.delete |
| g_sheet | POST | `/sheet/{tab}/append` | settings.edit |
| g_sheet | PATCH | `/sheet/{tab}/update` | settings.edit |
| g_sheet | POST | `/sheet/{tab}/clear` | settings.edit |
| inbox | GET | `/email/fetch` | PUBLIC |
| inbox | POST | `/email/sync` | custom: inbox_sync_caller |
| inbox | GET | `/email/sync/fetch` | inbox.view |
| inbox | GET | `/inbox/fetch` | PUBLIC |
| inbox | POST | `/inbox/{record_id}/match` | inbox.edit |
| inbox | PATCH | `/inbox/{record_id}/assign-job-post` | inbox.edit |
| inbox | PATCH | `/inbox/{record_id}/assign-recruiter` | inbox.edit |
| inbox | POST | `/inbox/{record_id}/read` | inbox.edit |
| inbox | PATCH | `/inbox/read` | inbox.edit |
| inbox | PATCH | `/inbox/read-all` | inbox.edit |
| inbox | GET | `/inbox/{record_id}/read-status` | inbox.edit |
| inbox | GET | `/inbox/all-applications` | inbox.view |
| inbox | GET | `/inbox/all-applications/count` | inbox.view |
| inbox | GET | `/inbox/counts` | inbox.view |
| inbox | GET | `/inbox/triage` | inbox.view |
| inbox | PATCH | `/inbox/triage/{record_id}/override` | inbox.edit |
| inbox | PATCH | `/inbox/{record_id}/processing-state` | inbox.edit |
| inbox | PATCH | `/inbox/{record_id}/duplicate` | inbox.edit |
| inbox | POST | `/email/send` | inbox.edit |
| inbox | POST | `/email/reply` | inbox.edit |
| inbox | POST | `/inbox/rescan-on-hold` | inbox.edit |
| inbox | GET | `/inbox/rescan-on-hold` | inbox.view |
| interview | POST | `/interview/{interview_id}/calendar-event` | interviews.create |
| interview | PATCH | `/interview/{interview_id}/calendar-event/reschedule` | interviews.edit |
| interview | POST | `/interview/{interview_id}/calendar-event/cancel` | interviews.edit |
| job | GET | `/jobs/alias` | PUBLIC |
| job | POST | `/candidate/create/candidate` | candidates.create |
| job | GET | `/candidate/fetch/users` | candidates.view |
| job | GET | `/candidate/fetch/users/count` | candidates.view |
| job | POST | `/candidate/cv_upload` | candidates.create |
| job | POST | `/candidate/cv-bank/upload` | candidates.create |
| job | GET | `/candidate/cv-bank/fetch` | candidates.view |
| job | POST | `/candidate/cv-bank/score` | candidates.create |
| job | GET | `/candidate/cv-bank/suggestions` | candidates.view |
| job | GET | `/candidate/cv-bank/file` | candidates.view |
| job | DELETE | `/candidate/cv-bank/delete` | candidates.delete |
| job | GET | `/candidate/matching/fetch` | candidates.view |
| job | GET | `/candidate/matching/fetch_by_id` | candidates.view |
| job | POST | `/candidate/matching/assign` | candidates.edit |
| job | POST | `/candidate/inbox-match` | candidates.edit |
| job | POST | `/job/post-job` | job_board.create |
| job | POST | `/job/image/upload` | job_board.create OR jobs.edit |
| job | GET | `/job/image/fetch` | jobs.view OR job_board.view |
| job | POST | `/job/assist-field` | job_board.create OR jobs.edit |
| job | GET | `/job/buffer/channels` | job_board.view |
| job | POST | `/candidate/score` | candidates.create |
| job | POST | `/candidate/score_inbox` | candidates.create |
| job | POST | `/candidate/ats-rerun` | candidates.create |
| job | GET | `/candidate/scored/fetch` | candidates.view |
| job | GET | `/job/fetch` | job_board.view OR candidates.view OR talent.view |
| job | GET | `/job/stats/fetch` | jobs.view OR pipeline.view |
| job | GET | `/job/departments/fetch` | job_board.view OR candidates.view OR talent.view OR jobs.view |
| job | GET | `/jobs/requisition-statuses/fetch` | jobs.view |
| job | GET | `/jobs/status-history/fetch` | jobs.view |
| job | GET | `/jobs/fetch` | jobs.view |
| job | GET | `/jobs/export` | jobs.export |
| job | GET | `/candidate/fetch_by_id` | candidates.view |
| job | GET | `/candidate/manager/fetch` | candidates.view |
| job | GET | `/candidate/fetch` | candidates.view |
| job | GET | `/candidate/applications/fetch` | candidates.view |
| job | PATCH | `/candidate/update` | candidates.edit |
| job | GET | `/candidate/history/fetch` | candidates.view |
| job | GET | `/interview/fetch` | interviews.view OR candidates.view |
| job | POST | `/interview/create` | interviews.create OR candidates.create |
| job | PATCH | `/interview/update` | interviews.edit OR candidates.edit |
| job | GET | `/notes/fetch` | candidates.view |
| job | POST | `/notes/create` | candidates.create |
| job | PATCH | `/notes/update` | candidates.edit |
| job | GET | `/activity/fetch` | candidates.view |
| job | POST | `/activity/create` | candidates.create |
| job | GET | `/feedback/fetch` | candidates.view |
| job | POST | `/feedback/create` | candidates.create |
| job | PATCH | `/feedback/update` | candidates.edit |
| job | PATCH | `/candidate/stage` | pipeline.edit |
| job | GET | `/pipeline/candidates/fetch` | pipeline.view |
| job | GET | `/pipeline/candidate/score/fetch` | pipeline.view |
| job | GET | `/pipeline/transitions/fetch` | pipeline.view |
| job | GET | `/job/assignments/fetch` | jobs.view |
| job | POST | `/job/assignments/create` | jobs.edit |
| job | GET | `/candidate/assignments/fetch` | candidates.view |
| job | POST | `/candidate/assignments/create` | candidates.edit |
| job | GET | `/job/costs/fetch` | jobs.view |
| job | GET | `/job/costs/source-channels/fetch` | jobs.view |
| job | POST | `/job/costs/create` | jobs.edit |
| job | PATCH | `/jobs/update` | jobs.edit |
| job | DELETE | `/jobs/delete` | jobs.delete |
| job | PATCH | `/jobs/status` | jobs.edit |
| job | GET | `/feedback/templates/fetch` | candidates.view |
| job | POST | `/feedback/templates/create` | candidates.create |
| job | PATCH | `/feedback/templates/update` | candidates.edit |
| job | DELETE | `/feedback/templates/delete` | candidates.delete |
| job | GET | `/documents/download` | candidates.view |
| notifications | POST | `/users/confirm-email` | PUBLIC |
| notifications | POST | `/users/confirm-email/resend` | PUBLIC |
| notifications | GET | `/notifications/fetch` | any authenticated user |
| notifications | POST | `/notifications/{record_id}/read` | any authenticated user |
| notifications | POST | `/notifications/read-all` | any authenticated user |
| notifications | DELETE | `/notifications/delete` | any authenticated user |
| offer | GET | `/offers/fetch` | offers.view |
| offer | POST | `/offers/create` | offers.create |
| offer | PATCH | `/offers/update` | offers.edit |
| offer | POST | `/offers/issue` | offers.approve |
| offer | GET | `/offers/jobs/candidates/lists` | offers.view |
| offer | POST | `/offers/jobs/sent` | offers.create |
| org_settings | GET | `/org-settings/fetch` | settings.view |
| org_settings | PUT | `/org-settings/update` | settings.configure |
| org_settings | GET | `/org-settings/exclude-university/fetch` | settings.view |
| org_settings | POST | `/org-settings/exclude-university/create` | settings.configure |
| org_settings | POST | `/org-settings/exclude-university/create-batch` | settings.configure |
| org_settings | PATCH | `/org-settings/exclude-university/update` | settings.configure |
| org_settings | DELETE | `/org-settings/exclude-university/delete` | settings.configure |
| org_settings | GET | `/org-settings/exclude-company/fetch` | settings.view |
| org_settings | POST | `/org-settings/exclude-company/create` | settings.configure |
| org_settings | POST | `/org-settings/exclude-company/create-batch` | settings.configure |
| org_settings | PATCH | `/org-settings/exclude-company/update` | settings.configure |
| org_settings | DELETE | `/org-settings/exclude-company/delete` | settings.configure |
| reports | GET | `/reports/fetch` | reports.view |
| reports | POST | `/reports/create` | reports.create |
| reports | PATCH | `/reports/update` | reports.edit |
| reports | DELETE | `/reports/delete` | reports.delete |
| reports | POST | `/reports/run` | reports.view |
| reports | GET | `/reports/export` | reports.export |
| reports | GET | `/reports/runs/fetch` | reports.view |
| role | GET | `/roles/fetch` | rbac_users.view |
| role | POST | `/roles/create` | rbac_users.create |
| role | PUT | `/roles/update` | rbac_users.edit |
| role | DELETE | `/roles/delete` | rbac_users.delete |
| role | GET | `/permissions/fetch` | rbac_users.view |
| role | POST | `/permissions/create` | rbac_users.manage |
| role | PUT | `/permissions/update` | rbac_users.manage |
| role | PUT | `/roles/matrix/update` | rbac_users.edit |
| role | PUT | `/roles/permission-tags/update` | rbac_users.manage |
| role | GET | `/permission-tags/fetch` | rbac_users.view |
| s3 | GET | `/s3/health` | PUBLIC |
| s3 | POST | `/s3/upload` | candidates.create OR settings.edit |
| s3 | GET | `/s3/url` | candidates.view OR settings.view |
| s3 | GET | `/s3/open` | candidates.view OR settings.view OR inbox.view |
| s3 | GET | `/s3/download` | candidates.view OR settings.view OR inbox.view |
| s3 | POST | `/s3/delete` | candidates.delete OR settings.delete |
| saved_search | GET | `/saved-searches/fetch` | any authenticated user |
| saved_search | POST | `/saved-searches/create` | any authenticated user |
| saved_search | PATCH | `/saved-searches/update` | any authenticated user |
| saved_search | DELETE | `/saved-searches/delete` | any authenticated user |
| search | GET | `/search/fetch` | jobs.view OR candidates.view |
| talent | POST | `/talent/runs/start` | talent.create |
| talent | GET | `/talent/runs/status` | talent.view |
| talent | GET | `/talent/account` | talent.view |
| talent | GET | `/talent/runs/fetch` | talent.view |
| talent | GET | `/talent/profiles/fetch` | talent.view |
| talent | GET | `/talent/profiles/fetch_by_id` | talent.view |
| talent | PATCH | `/talent/profiles/outreach` | talent.edit |
| talent | DELETE | `/talent/profiles/delete` | talent.delete |
| tasks | GET | `/tasks/fetch` | tasks.view |
| tasks | GET | `/tasks/assignees/fetch` | tasks.view |
| tasks | POST | `/tasks/create` | tasks.create |
| tasks | PATCH | `/tasks/update` | tasks.edit |
| tasks | DELETE | `/tasks/delete` | tasks.delete |
| users | POST | `/users/login` | PUBLIC |
| users | POST | `/users/signup` | PUBLIC |
| users | POST | `/users/refresh` | PUBLIC |
| users | GET | `/users/me` | any authenticated user |
| users | POST | `/users/create` | rbac_users.create |
| users | GET | `/users/pending-approvals` | settings.view |
| users | PUT | `/users/approve` | rbac_users.edit |
| users | GET | `/users/fetch` | rbac_users.view |
| users | PUT | `/users/update` | rbac_users.edit |
| users | PUT | `/users/assign-role` | rbac_users.edit |
| users | PUT | `/users/remove-role` | rbac_users.edit |
| users | DELETE | `/users/delete` | rbac_users.delete |
| users | GET | `/managers/fetch` | jobs.view OR candidates.view OR job_board.create |

12
appspec.yml Normal file
View File

@ -0,0 +1,12 @@
version: 0.0
os: linux
files:
- source: /
destination: /opt/codedeploy-extracted-utopia-ai-hr-ats-portal-dev
file_exists_behavior: OVERWRITE
hooks:
AfterInstall:
- location: deploy_utopia-ai-hr-ats-portal-dev.sh
timeout: 600
runas: root

View File

@ -4,48 +4,55 @@ Copy everything below the line into any LLM session before asking it to write or
---
You are coding inside **HR-ATS-Portal** (`backend/`). You must follow this house style **exactly**. Mirror neighboring files. Do not invent alternate patterns, layers, or response shapes. Prefer matching existing code over “cleaner” industry defaults.
You are coding inside **HR-ATS-Portal** (`backend/`). Follow this house style **exactly**. Mirror the reference flow below line-for-line in shape. Do not invent layers, response shapes, flags, guards, or "defensive" checks the reference does not have. Write **no more and no less** than the reference does for the same job.
## Goal
## Reference flow (the canonical example)
Every change must look like it was written by the same author as `backend/users/` and `backend/inbox/`.
`POST /forms/requisition/create` in `backend/candidate_forms/`. Every new endpoint copies this shape.
## Package layout (every domain)
```
backend/<domain>/
app.py # routes only — HTTP in/out
views.py # service class — business logic
models.py # SQLModel table + classmethod DB accessors
serializers.py # hand-rolled dict builders (no Pydantic response models)
plugins.py # pure helpers (hash, JWT, clean payload) — NO FastAPI imports
permissions.py # OAuth2 scheme + Depends aliases (auth domains only)
```
- Bare `router = APIRouter()`; mount in `main.py` with `app.include_router(...)`.
- No package `__init__.py`. Run from `backend/` so imports are top-level (`users.app`, `db_setup`).
- Non-DB config: module-level `load_dotenv()` + `os.getenv(...)`. Do **not** extend `db_setup.Settings` for app secrets.
## Layer duties (non-negotiable)
| Layer | Owns | Must NOT do |
|---|---|---|
| `app.py` | Routes, inline request Pydantic models, `JSONResponse`, HTTP token envelope via serializers, inject `session` / `CurrentUser` | Business rules, SQL, JWT crypto beyond calling plugin functions |
| `views.py` | Business checks, call models, raise `HTTPException`, return ORM user (auth) or serialized dict (CRUD) | Call `serialize_token` or build login HTTP payloads |
| `models.py` | Fields, queries, inserts/updates/soft-delete, `selectinload` when needed | HTTPException, FastAPI, serializers |
| `serializers.py` | `serialize_*` → plain `dict` | DB, Depends |
| `plugins.py` | Pure helpers; raise library errors (`jwt.*`) | Import FastAPI / raise HTTPException |
| `permissions.py` | `OAuth2PasswordBearer`, `get_current_user`, `CurrentUser` alias, `require_permission` | Route handlers |
## Exact route pattern (`app.py`)
- Paths are verb-in-path: `/users/create`, `/users/fetch`, `/users/login`**not** `/auth/token`, not REST-resource-only.
- Standard wrapper on every handler:
### 1. `enums.py` — enums + nested payload blocks
```python
class EmploymentType(str,Enum):
PERMANENT = "permanent"
CONTRACT = "contract"
class Position(BaseModel):
department_id:uuid.UUID
department:Optional[str]
title:Optional[str]
type:Optional[EmploymentType]
period_from:Optional[date]=None
```
- `str, Enum` classes and the nested `BaseModel` blocks a request body is built from live here.
### 2. `app.py` — request models inline + thin route
```python
class RequisitionFormCreate(BaseModel):
form_type: str = "requisition"
position:Position
replacement_for:Optional[ReplacementFor]
initiated_by:Optional[str]
approved_by_hr:Optional[bool]
class RequisitionFormUpdate(BaseModel):
position:Optional[Position]=None
initiated_by:Optional[str]=None
approved_by_hr:Optional[bool]=None
@router.post("/forms/requisition/create")
async def create_requisition_form(
payload: RequisitionFormCreate,
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_CREATE)),
session:AsyncSession=Depends(get_session),
):
try:
service=User(session=session)
data=await service.some_method(...)
service = RequisitionForm(session=session)
data = await service.create_form(payload.model_dump(exclude_unset=True), current_user)
return JSONResponse(content={"data": data, "status_code": 200})
except HTTPException:
raise
@ -53,78 +60,255 @@ except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
```
- List: `{"data":items,"total":total,"status_code":200}`. By id: include `"total":1`.
- Login / refresh — **service returns ORM user**; route mints tokens and serializes:
- `router = APIRouter()`; mounted in `main.py` via `app.include_router(...)`.
- Paths are verb-in-path: `/<domain>/create`, `/<domain>/fetch`, `/<domain>/update`, `/<domain>/delete`, `/<domain>/search`. No `/api/v1`, no REST-resource-only paths.
- Methods: `post` create, `get` fetch/search/count, `patch` update, `delete` delete.
- Param order: `payload``current_user` → query params → `session`.
- Record id comes as a query param: `form_id:str=Query(...)` (required) or `Query(None)` (fetch one-or-all).
- Body goes to the service as `payload.model_dump(exclude_unset=True)`.
- Protection: `current_user:dict=Depends(require_permission(PermissionTag.X))`. Several tags: `require_permission(A, B, require_all=False)`.
- The route body is **only** the try block above: build service, one `await`, `JSONResponse`. Nothing else.
- Envelope: `{"data": data, "status_code": 200}`. List with count: add `"total"`. A scalar goes inside `data` as a dict (`{"data":{"open":data},...}`).
- Request models stay inline in `app.py` — Create has required fields without defaults, Update has every field `Optional[...]=None`.
### 3. `views.py` — service class
```python
user=await service.authenticate_user(form_data.username,form_data.password)
tokens=serialize_token(create_access_token(user),create_refresh_token(user),user)
return JSONResponse(content={**tokens,"status_code":200})
```
- Request body models stay **inline in `app.py`** (`UserCreate`, `UserUpdate`, `TokenRefresh`). Never move them into `serializers.py`.
- Use `session: AsyncSession = Depends(get_session)` by default. Use `Annotated` only when required (`OAuth2PasswordRequestForm`, `CurrentUser` before other defaulted params).
- Preserve tight local spacing: `service=User(session=session)`, `detail=str(e)`. Do not pretty-reformat unrelated code.
## Exact service pattern (`views.py`)
```python
class User:
class RequisitionForm:
def __init__(self, session: AsyncSession):
self.session = session
async def create_user(self,payload):
...
return serialize_user(user)
async def create_form(self, payload, current_user):
payload["position"]["department_id"] = _as_uuid(payload["position"]["department_id"])
payload["created_by"] = _user_id(current_user)
row = await Requisition.insert_form(self.session, payload)
return serialize_requisition(row)
async def update_form(self, form_id, payload, current_user):
_user_id(current_user)
row = await Requisition.get_form_by_id(self.session, form_id)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
if not payload:
raise HTTPException(status_code=400, detail="No fields to update")
updated = await Requisition.update_form(self.session, form_id, payload)
if not updated:
raise HTTPException(status_code=404, detail="Form not found")
return serialize_requisition(updated)
async def get_form_by_id(self, form_id, current_user):
created_by = None if is_admin(current_user) else _user_id(current_user)
if form_id:
row = await Requisition.get_form_by_id(self.session, record_id=form_id, created_by=created_by)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
return serialize_requisition(row)
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
return [serialize_requisition(r) for r in rows]
```
- Leave service method parameters untyped (match existing).
- Raise `HTTPException(status_code=...,detail="...")` for domain errors.
- `authenticate_user` / `refresh_access_token` return the **Users ORM instance only**.
- If you loaded via an accessor that does not `selectinload(role)`, re-fetch with `get_user_by_id` before serialization that touches `user.role`. (`get_user_by_email` and `get_user_by_id` both eager-load `role` today.)
- One class per resource, `__init__(self, session: AsyncSession)` only.
- Method parameters are **untyped**.
- A create method is: adjust the payload dict in place (coerce ids with plugin helpers, stamp `created_by`) → call **one** model classmethod → return the serializer. That is the whole method.
- Business errors: `raise HTTPException(status_code=..., detail="...")` — 404 not found, 400 empty update, 401 auth, 422 invalid value.
- Scope reads with `created_by = None if is_admin(current_user) else _user_id(current_user)`.
- Always return serialized dicts / lists of dicts, never ORM rows.
## Exact model pattern (`models.py`)
### 4. `plugins.py` — shared helpers
- SQLModel `table=True`; accessors as `@classmethod async def`.
- Soft delete sets `is_deleted=True` and `is_active=False`.
- `selectinload` relations that serializers read.
- Commits happen inside write accessors (existing convention).
```python
def _as_uuid(value):
if value in (None, ""):
return None
try:
return uuid.UUID(str(value))
except (TypeError, ValueError):
return None
## Serializers
- Hand-built dicts only. `str(uuid)`, `.isoformat()` for datetimes. Never include `password`.
- Token response: OAuth2 fields at **root** (`access_token`, `refresh_token`, `token_type`, `expires_in`); user record under `data`.
def _user_id(current_user):
if not current_user or not current_user.get("id"):
raise HTTPException(status_code=401, detail="Not authenticated")
uid = _as_uuid(current_user["id"])
if uid is None:
raise HTTPException(status_code=401, detail="Invalid user id")
return uid
```
## Auth (when touching users auth)
- Every helper function and constant (`_as_uuid`, `_user_id`, `_aware`, validators, `FORM_TYPES`, definitions payloads) lives in `<domain>/plugins.py` and is imported by name into `views.py`. Never define helpers at the top of `views.py`, `models.py`, `app.py`, or `serializers.py`.
- Helpers may raise `HTTPException` when they validate request data.
- Before writing a helper, check the domain's `plugins.py` and reuse what exists.
- PyJWT access + refresh with a `type` claim; `decode_token(..., expected_type=...)` rejects mismatches.
- Protect `/users/*` with `current_user: CurrentUser` except `/users/login` and `/users/refresh`. Prefer `Depends(require_permission(...))` on mutating/list routes that need a specific tag; keep `/users/me` on plain `CurrentUser` so users can discover a missing-role state.
- `get_current_user`: decode access → DB by `sub` → reject missing/deleted/inactive → return `serialize_user(user, with_permissions=True)`.
- Login: `OAuth2PasswordRequestForm` (username = email). `tokenUrl="users/login"` (no leading slash).
- JWT `iat`/`exp` use `datetime.now(timezone.utc)` only — never naive `datetime.now()`.
### 5. `models.py` — SQLModel table + classmethod accessors
## Dependencies / env
```python
class Requisition(SQLModel, table=True):
__tablename__ = "requisitions"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id")
department_ref: Optional["Department"] = Relationship(
back_populates="requisitions",
sa_relationship_kwargs={"uselist": False, "lazy": "selectin"},
)
position_title: Optional[str] = None
created_by: Optional[uuid.UUID] = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_deleted: bool = Field(default=False)
- Add pins to `backend/requirements.txt` under banner comments with a trailing `# why` comment.
- Put secrets in `backend/.env`; keep key names in `backend/.env.example`.
@classmethod
async def get_form_by_id(cls, session: AsyncSession, record_id=None, created_by=None):
qry = select(cls).where(cls.is_deleted == False) # noqa: E712
if created_by is not None:
qry = qry.where(cls.created_by == created_by)
if record_id not in (None, ""):
try:
uid = uuid.UUID(str(record_id))
except (TypeError, ValueError):
return None
qry = qry.where(cls.id == uid)
result = await session.execute(qry)
return result.scalars().first()
result = await session.execute(qry.order_by(cls.created_at.desc(),cls.id.desc()))
return list(result.scalars().all())
## Hard bans
@classmethod
async def insert_form(cls, session: AsyncSession, fields: dict):
position = fields.get("position") if fields.get("position") else {}
row = cls(
department_id=position.get("department_id") if position.get("department_id") else None,
position_title=position.get("title") if position.get("title") else None,
employment_type=EmploymentType(position.get("type")) if position.get("type") else None,
jd_available=position.get("jd_available") if position.get("jd_available") is not None else None,
initiated_by=fields.get("initiated_by") if fields.get("initiated_by") else None,
created_by=fields.get("created_by") if fields.get("created_by") else None,
)
session.add(row)
await session.commit()
return await cls.get_form_by_id(session, row.id)
1. No repository / use-case / DTO layers beyond inline request models.
2. No Pydantic response models; no alternate envelopes; no `/api/v1` prefix.
3. No `serialize_token` inside `views.py`.
4. No FastAPI imports in `plugins.py`.
5. No drive-by refactors, renames, or whole-file reformats.
6. RBAC exists in `users/permissions.py`; do not invent a second scheme.
7. Do not edit unrelated domains (`inbox/` vs `users/`) unless asked.
8. Do not add `__init__.py` to make packages.
@classmethod
async def update_form(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_form_by_id(session, record_id)
if not row:
return None
if "position" in fields:
position = fields.get("position") if fields.get("position") else {}
if "title" in position:
row.position_title = position.get("title") if position.get("title") else None
if "initiated_by" in fields:
row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def soft_delete_form(cls, session: AsyncSession, record_id):
row = await cls.get_form_by_id(session, record_id)
if not row:
return None
row.is_deleted = True
row.updated_at = _now()
session.add(row)
await session.commit()
return row
```
- Standard columns on every table: `id` uuid4 PK, `created_by` FK `users.id`, `created_at` / `updated_at` tz-aware via `_now`, `is_deleted`.
- All DB access is `@classmethod async def` taking `session` first. No free functions, no repository class.
- **One accessor does one whole job.** Insert maps the full (nested) payload dict to columns, adds, commits, and re-fetches in the same method. Never split a create into several helper calls or a second "create child" function when the mapping fits inline.
- **One reader for one-or-many:** `get_form_by_id` returns a row when `record_id` is given, a list otherwise. Do not add a separate `fetch_all`.
- Column mapping idiom: `x=src.get("k") if src.get("k") else None`; booleans use `is not None`; enums wrap `Enum(value)`.
- Update idiom: `if "k" in fields:` per field (nested blocks: `if "block" in fields:` then per-key), then `row.updated_at = _now()`, add, commit, refresh, return row. Return `None` when missing — the view raises.
- Soft delete only: `is_deleted = True` (+ `updated_at`). Reads always filter `cls.is_deleted == False # noqa: E712`.
- Cross-domain relations: model name in quotes, import under `if TYPE_CHECKING:`, `back_populates` on both sides, and a bottom-of-file `import <other>.models as _<other>_models # noqa: E402, F401`.
- Commits happen inside write accessors; views never commit.
### 6. `serializers.py` — hand-built dicts
```python
def _date(value):
return value.isoformat() if value else None
def serialize_requisition(row) -> dict:
return {
"id": str(row.id) if row.id else None,
"position": {
"department_id": str(row.department_id) if row.department_id else None,
"title": row.position_title,
"date": _date(row.date),
"type": _enum(row.employment_type),
},
"initiated_by": row.initiated_by,
"created_by": str(row.created_by) if row.created_by else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
```
- `serialize_<resource>(row) -> dict`. UUIDs `str(...) if ... else None`, dates `.isoformat()`, enums via `_enum`.
- The response shape mirrors the request shape (nested blocks back out as nested dicts), so the frontend sends and receives the same structure.
- No DB access, no Pydantic response models. Never include passwords.
### 7. Schema change → manual SQL migration
- New column / FK / index: add `backend/migrations/manual/<next_number>_<snake_name>.sql` with a header comment explaining why, idempotent DDL (`ADD COLUMN IF NOT EXISTS`, guarded `ADD CONSTRAINT`, `CREATE INDEX IF NOT EXISTS`), schema `app.`. Never edit an already-applied migration file.
## Package layout (every domain)
```
backend/<domain>/
app.py # routes + inline request models
views.py # service class
models.py # SQLModel table + classmethod accessors
serializers.py # serialize_* dict builders
enums.py # str Enums + nested request BaseModel blocks
plugins.py # helpers + constants
permissions.py # auth domains only
```
- No `__init__.py`. Run from `backend/`; imports are top-level (`candidate_forms.views`, `db_setup`, `users.permissions`).
- Config: module-level `load_dotenv()` + `os.getenv(...)`. Do not extend `db_setup.Settings` for app secrets.
## Layer duties
| Layer | Owns | Must NOT |
|---|---|---|
| `app.py` | Routes, inline request models, `Depends`, `JSONResponse` envelope | Business rules, SQL, helper functions |
| `views.py` | Payload prep, business checks, `HTTPException`, call model, return serializer | SQL, commits, helper definitions |
| `models.py` | Columns, relationships, queries, insert/update/soft-delete + commit | `HTTPException`, serializers |
| `serializers.py` | `serialize_*``dict` | DB, `Depends` |
| `enums.py` | Enums, nested request blocks | Logic |
| `plugins.py` | Every helper and constant | Routes, DB writes |
## Workflow when adding an endpoint
1. Model accessor (if DB).
2. Service method in `views.py`.
3. `serialize_*` if new shape.
4. Route in `app.py` with the standard try/except + `JSONResponse`.
5. Add `current_user: CurrentUser` or `Depends(require_permission(...))` if the route is protected.
1. Migration SQL if the schema changes.
2. Columns + one classmethod accessor in `models.py`.
3. Enum / nested block in `enums.py` if the body has one.
4. Helper in `plugins.py` only if a view needs one that does not already exist.
5. `serialize_*` in `serializers.py` if the shape is new.
6. Service method in `views.py`.
7. Inline request model + route in `app.py` with the exact try/except + `JSONResponse` wrapper and `require_permission`.
Before finishing, re-read the touched files and confirm they still match a sibling files structure, naming, spacing, and response shape.
## Auth (only when touching `users/`)
- Login / refresh: service returns the **Users ORM row**; the route mints tokens and calls `serialize_token(...)` — never inside `views.py`. Tokens at the root, user under `data`.
- PyJWT access + refresh with a `type` claim; `iat`/`exp` use `datetime.now(timezone.utc)`.
- RBAC lives in `users/permissions.py` (`require_permission`, `is_admin`, `CurrentUser`). Do not invent a second scheme.
## Hard bans
1. No extra flags, params, guards, try/excepts, logging, or validations the reference flow does not have.
2. No splitting one create/update into multiple helper functions or extra model calls when one classmethod does it.
3. No helpers defined outside `plugins.py`; no duplicated helpers — import the existing one.
4. No repository / use-case / DTO layers; no Pydantic response models; no alternate envelopes; no `/api/v1`.
5. No hard deletes.
6. No drive-by refactors, renames, reformatting, or edits to unrelated domains.
7. No `__init__.py`.
8. Dependencies: pin in `backend/requirements.txt` with a `# why` comment; secret names in `backend/.env.example`.
Before finishing, put the new code next to the reference flow above and remove anything the reference would not have.

View File

@ -13,6 +13,12 @@ Django-shaped aliases (same behaviour, different names):
python alembic_setup.py upgrade # apply versions/*.py (like migrate)
python alembic_setup.py 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()

View File

@ -78,7 +78,7 @@ async def search_requisitions(
require_all=False,
)
),
q: str | None = Query(None),
search: str | None = Query(None),
top: int = Query(50, ge=1, le=100),
job_post_id: uuid.UUID | None = Query(None),
session: AsyncSession = Depends(get_session),
@ -91,7 +91,7 @@ async def search_requisitions(
"""
try:
service = RequisitionForm(session=session)
data = await service.search(q, top=top, job_post_id=job_post_id)
data = await service.search(search, top=top, job_post_id=job_post_id)
return JSONResponse(content={"data": data, "status_code": 200})
except HTTPException:
raise
@ -99,6 +99,22 @@ async def search_requisitions(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/forms/requisition/open-count")
async def count_open_requisitions(
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
session:AsyncSession=Depends(get_session),
):
"""Open requisitions: unlinked, or linked to a job post that is still open."""
try:
service=RequisitionForm(session=session)
data=await service.count_open(current_user)
return JSONResponse(content={"data":{"open":data},"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/forms/requisition/fetch")
async def fetch_requisition_form(
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),

View File

@ -2,7 +2,7 @@ from enum import Enum
from pydantic import BaseModel
from typing import Optional
from datetime import date
import uuid
class EmploymentType(str,Enum):
PERMANENT = "permanent"
CONTRACT = "contract"
@ -10,6 +10,7 @@ class EmploymentType(str,Enum):
INTERNEE="internee"
class Position(BaseModel):
department_id:uuid.UUID
department:Optional[str]
title:Optional[str]
date:Optional[date]

View File

@ -2,13 +2,13 @@ import uuid
from datetime import datetime, date as Date, timezone
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, Enum as SAEnum, JSON, func, or_
from sqlalchemy import DateTime, Enum as SAEnum, JSON, and_, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select
from candidate_forms.enums import EmploymentType
if TYPE_CHECKING:
from department.models import Department
from job.job_post.models import JobPosts
@ -20,6 +20,11 @@ class Requisition(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
department: Optional[str] = None
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id")
department_ref: Optional["Department"] = Relationship(
back_populates="requisitions",
sa_relationship_kwargs={"uselist": False, "lazy": "selectin"},
)
position_title: Optional[str] = None
date: Optional[Date] = None
date_needed: Optional[Date] = None
@ -94,7 +99,7 @@ class Requisition(SQLModel, table=True):
async def search(
cls,
session: AsyncSession,
q: str | None = None,
search: str | None = None,
*,
top: int = 50,
job_post_id=None,
@ -120,7 +125,7 @@ class Requisition(SQLModel, table=True):
if except_uid is not None:
held = held.where(JobPosts.id != except_uid)
statement = statement.where(cls.id.notin_(held))
term = (q or "").strip()
term = (search or "").strip()
if term:
like = f"%{term}%"
statement = statement.where(
@ -131,6 +136,32 @@ class Requisition(SQLModel, table=True):
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def count_open(cls, session: AsyncSession, created_by=None) -> int:
"""Open requisitions: not linked to a live job post, or linked to one whose
requisition_status is still open. A closed / on-hold / completed job closes
its requisition. job_posts.requisition_id is 1:1, so the join never fans out.
"""
from job.job_post.enums import RequisitionStatus
from job.job_post.models import JobPosts
statement = (
select(func.count())
.select_from(cls)
.outerjoin(
JobPosts,
and_(JobPosts.requisition_id == cls.id, JobPosts.is_deleted == False), # noqa: E712
)
.where(
cls.is_deleted == False, # noqa: E712
or_(JobPosts.id.is_(None), JobPosts.requisition_status == RequisitionStatus.OPEN.value),
)
)
if created_by is not None:
statement = statement.where(cls.created_by == created_by)
result = await session.execute(statement)
return int(result.scalar_one())
@classmethod
async def insert_form(cls, session: AsyncSession, fields: dict):
position = fields.get("position") if fields.get("position") else {}
@ -138,6 +169,7 @@ class Requisition(SQLModel, table=True):
referral = fields.get("refferal_by") if fields.get("refferal_by") else {}
row = cls(
department=position.get("department") if position.get("department") else None,
department_id=position.get("department_id") if position.get("department_id") else None,
position_title=position.get("title") if position.get("title") else None,
date=position.get("date") if position.get("date") else None,
date_needed=position.get("date_needed") if position.get("date_needed") else None,
@ -181,6 +213,8 @@ class Requisition(SQLModel, table=True):
position = fields.get("position") if fields.get("position") else {}
if "department" in position:
row.department = position.get("department") if position.get("department") else None
if "department_id" in position:
row.department_id = position.get("department_id") if position.get("department_id") else None
if "title" in position:
row.position_title = position.get("title") if position.get("title") else None
if "date" in position:
@ -391,3 +425,4 @@ class CandidateForms(SQLModel, table=True):
import users.models as _users_models # noqa: E402, F401
import department.models as _department_models # noqa: E402, F401

View File

@ -1,11 +1,63 @@
"""Pure helpers for the hiring forms domain — no FastAPI, no DB.
import uuid
from fastapi import HTTPException
from datetime import timezone
def _as_uuid(value):
if value in (None, ""):
return None
try:
return uuid.UUID(str(value))
except (TypeError, ValueError):
return None
def _user_id(current_user):
if not current_user or not current_user.get("id"):
raise HTTPException(status_code=401, detail="Not authenticated")
uid = _as_uuid(current_user["id"])
if uid is None:
raise HTTPException(status_code=401, detail="Invalid user id")
return uid
def _aware(value):
if value is not None and getattr(value, "tzinfo", None) is None:
return value.replace(tzinfo=timezone.utc)
return value
def _stage_value(status) -> str:
return str(getattr(status, "value", status) or "").upper()
def _recommendation(form_type, value):
definition = FORM_DEFINITIONS.get(form_type) or {}
if not definition.get("has_recommendation"):
return None
if value in (None, ""):
return None
value = str(value).strip()
if value not in RECOMMENDATIONS:
raise HTTPException(
status_code=422,
detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}",
)
return value
def _score_sections(form_type, sections):
try:
return normalize_sections(form_type, sections)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
def _score_fields(form_type, fields):
try:
return normalize_fields(form_type, fields)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
FORM_DEFINITIONS is the single authority for section/criterion/field keys AND
their on-screen labels, which reproduce the paper annexures verbatim (Annexure A
Employee Requisition Form, Annexure E Interview Evaluation Form). The frontend
renders labels from /forms/definitions, and criterion labels are denormalized
into every saved row so historical records survive future renames.
"""
FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit")

View File

@ -71,6 +71,7 @@ def serialize_requisition_option(row) -> dict:
"id": str(row.id) if row.id else None,
"title": row.position_title,
"department": row.department,
"department_id": str(row.department_id) if row.department_id else None,
"label": f"{title or 'Untitled'} - {department or ''}",
}
@ -80,6 +81,7 @@ def serialize_requisition(row) -> dict:
"id": str(row.id) if row.id else None,
"position": {
"department": row.department,
"department_id": str(row.department_id) if row.department_id else None,
"title": row.position_title,
"date": _date(row.date),
"date_needed": _date(row.date_needed),

View File

@ -14,6 +14,13 @@ from candidate_forms.plugins import (
combined_summary,
normalize_fields,
normalize_sections,
_as_uuid,
_user_id,
_aware,
_stage_value,
_recommendation,
_score_sections,
_score_fields,
)
from candidate_forms.serializers import (
serialize_form,
@ -32,63 +39,6 @@ from users.permissions import is_admin, is_hiring_manager
logger = logging.getLogger("candidate_forms")
def _as_uuid(value):
if value in (None, ""):
return None
try:
return uuid.UUID(str(value))
except (TypeError, ValueError):
return None
def _user_id(current_user):
if not current_user or not current_user.get("id"):
raise HTTPException(status_code=401, detail="Not authenticated")
uid = _as_uuid(current_user["id"])
if uid is None:
raise HTTPException(status_code=401, detail="Invalid user id")
return uid
def _aware(value):
if value is not None and getattr(value, "tzinfo", None) is None:
return value.replace(tzinfo=timezone.utc)
return value
def _stage_value(status) -> str:
return str(getattr(status, "value", status) or "").upper()
def _recommendation(form_type, value):
definition = FORM_DEFINITIONS.get(form_type) or {}
if not definition.get("has_recommendation"):
return None
if value in (None, ""):
return None
value = str(value).strip()
if value not in RECOMMENDATIONS:
raise HTTPException(
status_code=422,
detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}",
)
return value
def _score_sections(form_type, sections):
try:
return normalize_sections(form_type, sections)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
def _score_fields(form_type, fields):
try:
return normalize_fields(form_type, fields)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
class CandidateForm:
def __init__(self, session: AsyncSession):
self.session = session
@ -406,6 +356,7 @@ class RequisitionForm:
self.session = session
async def create_form(self, payload, current_user):
payload["position"]["department_id"] = _as_uuid(payload["position"]["department_id"])
payload["created_by"] = _user_id(current_user)
row = await Requisition.insert_form(self.session, payload)
return serialize_requisition(row)
@ -435,8 +386,13 @@ class RequisitionForm:
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
return [serialize_requisition(r) for r in rows]
async def search(self, q, top=50, job_post_id=None):
async def count_open(self, current_user):
# Same scope as the requisition list: admins count every row, others their own.
created_by = None if is_admin(current_user) else _user_id(current_user)
return await Requisition.count_open(self.session, created_by=created_by)
async def search(self, search, top=50, job_post_id=None):
rows = await Requisition.search(
self.session, q, top=top, job_post_id=job_post_id,
self.session, search=search, top=top, job_post_id=job_post_id,
)
return [serialize_requisition_option(r) for r in rows]

164
backend/department/app.py Normal file
View File

@ -0,0 +1,164 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from db_setup import get_session
from department.views import DepartmentService
from users.permissions import PermissionTag, require_permission
from dotenv import load_dotenv
load_dotenv()
router = APIRouter()
class DepartmentCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
short_code: str = Field(min_length=1, max_length=10)
subtitle: str | None = Field(default=None, max_length=160)
description: str | None = None
is_active: bool = True
department_head_id: uuid.UUID | None = None
parent_department_id: uuid.UUID | None = None
location: list[str] = Field(default_factory=list)
class DepartmentUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
short_code: str | None = Field(default=None, min_length=1, max_length=10)
subtitle: str | None = Field(default=None, max_length=160)
description: str | None = None
is_active: bool | None = None
department_head_id: uuid.UUID | None = None
parent_department_id: uuid.UUID | None = None
location: list[str] | None = None
@router.get("/department/fetch")
async def fetch_departments(
current_user: dict = Depends(require_permission(PermissionTag.DEPARTMENT_VIEW)),
record_id: str | None = Query(None),
search: str | None = Query(None),
is_active: bool | None = Query(None),
top: int | None = Query(None, ge=1),
skip: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
):
try:
service = DepartmentService(session=session)
if record_id is not None:
item = await service.get_department(record_id)
return JSONResponse(content={"data": item, "total": 1, "status_code": 200})
items, total = await service.get_departments(top, skip, search, is_active)
return JSONResponse(content={"data": items, "total": total, "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/department/create")
async def create_department(
body: DepartmentCreate,
current_user: dict = Depends(require_permission(PermissionTag.DEPARTMENT_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service = DepartmentService(session=session)
item = await service.create_department(body.model_dump(), current_user.get("id"))
return JSONResponse(status_code=201, content={"data": item, "status_code": 201})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/department/update")
async def update_department(
body: DepartmentUpdate,
record_id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.DEPARTMENT_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service = DepartmentService(session=session)
item = await service.update_department(
record_id, body.model_dump(exclude_unset=True), current_user.get("id")
)
return JSONResponse(content={"data": item, "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/department/heads/fetch")
async def fetch_department_heads(
role_id:Optional[int] = Query(5),
top: Optional[int]=Query(None, ge=1),
skip: Optional[int]=Query(0, ge=0),
search: Optional[str]=Query(None),
current_user: dict = Depends(
require_permission(
PermissionTag.DEPARTMENT_CREATE,
PermissionTag.DEPARTMENT_EDIT,
require_all=False,
)
),
session: AsyncSession = Depends(get_session),
):
try:
service = DepartmentService(session=session)
items = await service.get_head_options(role_id,top,skip,search)
return JSONResponse(content={"data": items, "total": len(items), "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/department/locations/fetch")
async def fetch_department_locations(
search: Optional[str]=Query(None),
current_user: dict = Depends(
require_permission(
PermissionTag.DEPARTMENT_CREATE,
PermissionTag.DEPARTMENT_EDIT,
require_all=False,
)
),
session: AsyncSession = Depends(get_session),
):
try:
service = DepartmentService(session=session)
items = await service.get_location_options(search)
return JSONResponse(content={"data": items, "total": len(items), "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/department/names")
async def fetch_department_names(
search: Optional[str]=Query(None),
current_user: dict = Depends(
require_permission(
PermissionTag.DEPARTMENT_VIEW,
PermissionTag.JOB_BOARD_CREATE,
PermissionTag.JOBS_EDIT,
PermissionTag.REQUISITIONS_CREATE,
PermissionTag.REQUISITIONS_EDIT,
require_all=False,
)
),
session: AsyncSession = Depends(get_session),
):
try:
service = DepartmentService(session=session)
items = await service.get_department_names(search)
return JSONResponse(content={"data": items, "total": len(items), "status_code": 200})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,178 @@
from uuid import UUID
import uuid
from datetime import datetime
from typing import Optional, TYPE_CHECKING, List
from sqlalchemy import DateTime, func, or_
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
from sqlmodel import Field, Relationship, SQLModel, select
from department.plugins import as_uuid, now_utc
if TYPE_CHECKING:
from candidate_forms.models import Requisition
from job.job_post.models import JobPosts
class Department(SQLModel, table=True):
__tablename__ = "departments"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
name: str = Field(index=True, unique=True)
short_code: str = Field(index=True, unique=True, max_length=10)
# noload: selectin here would load every job post of a department whenever any job
# post loads its department_ref. Query JobPosts by department_id instead.
job_posts: List["JobPosts"] = Relationship(back_populates="department_ref", sa_relationship_kwargs={"lazy": "noload"})
requisitions: List["Requisition"] = Relationship(back_populates="department_ref", sa_relationship_kwargs={"lazy": "noload"})
subtitle: Optional[str] = Field(default=None)
description: Optional[str] = Field(default=None)
is_active: bool = Field(default=True)
parent_department_id: Optional[uuid.UUID] = Field(
default=None, index=True, foreign_key="departments.id"
)
department_head_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id")
location: list[str] = Field(
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"}
)
created_at: datetime = Field(default_factory=now_utc, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=now_utc, sa_type=DateTime(timezone=True))
created_by: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id")
updated_by: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id")
@classmethod
async def get_department_names(cls, session: AsyncSession, search: str | None):
statement = select(cls.id, cls.name).where(cls.is_active == True)
if search:
pattern = f"%{search}%"
statement = statement.where(
or_(
cls.name.ilike(pattern),
cls.short_code.ilike(pattern),
cls.subtitle.ilike(pattern),
)
)
statement = statement.order_by(cls.created_at.desc(),cls.id.desc())
result = await session.execute(statement)
return result.all()
@classmethod
def _filters(cls, search: str | None, is_active: bool | None):
clauses = []
if search:
pattern = f"%{search}%"
clauses.append(
or_(
cls.name.ilike(pattern),
cls.short_code.ilike(pattern),
cls.subtitle.ilike(pattern),
)
)
if is_active is not None:
clauses.append(cls.is_active == is_active)
return clauses
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id) -> "Department | None":
uid = as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def get_departments(
cls,
session: AsyncSession,
top: int | None = None,
skip: int = 0,
search: str | None = None,
is_active: bool | None = None,
) -> list["Department"]:
statement = select(cls).where(*cls._filters(search, is_active)).order_by(cls.name)
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def count_departments(
cls,
session: AsyncSession,
search: str | None = None,
is_active: bool | None = None,
) -> int:
statement = select(func.count()).select_from(cls).where(*cls._filters(search, is_active))
result = await session.execute(statement)
return int(result.scalar_one())
@classmethod
async def _commit(cls, session: AsyncSession, department: "Department") -> "Department":
"""Name/short-code uniqueness is the DB's unique indexes; IntegrityError propagates."""
session.add(department)
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise
await session.refresh(department)
return department
@classmethod
async def insert_department(cls, session: AsyncSession, fields: dict) -> "Department":
return await cls._commit(session, cls(**fields))
@classmethod
async def update_department(
cls, session: AsyncSession, record_id, fields: dict
) -> "Department | None":
department = await cls.get_by_id(session, record_id)
if not department:
return None
for key, value in fields.items():
setattr(department, key, value)
department.updated_at = now_utc()
return await cls._commit(session, department)
# @classmethod
# async def head_options(cls, session: AsyncSession):
# """Active users for the Department Head picker. COLUMN select, not the Users entity."""
# result = await session.execute(
# select(Users.id, Users.name, Users.email)
# .where(Users.is_deleted == False, Users.is_active == True) # noqa: E712
# .order_by(Users.name)
# )
# return result.all()
@classmethod
async def job_posts_for(cls, session: AsyncSession, department_ids):
"""(department_id, job_post_id, requisition_status) for non-deleted job posts
linked to these departments through job_posts.department_id.
"""
from job.job_post.models import JobPosts
ids = [i for i in (department_ids or []) if i]
if not ids:
return []
result = await session.execute(
select(JobPosts.department_id, JobPosts.id, JobPosts.requisition_status)
.where(JobPosts.department_id.in_(ids), JobPosts.is_deleted == False) # noqa: E712
)
return result.all()
@classmethod
async def names_by_ids(cls, session: AsyncSession, ids) -> dict[uuid.UUID, str]:
uids = {i for i in (ids or []) if i}
if not uids:
return {}
result = await session.execute(select(cls.id, cls.name).where(cls.id.in_(uids)))
return {row[0]: row[1] for row in result.all()}
import candidate_forms.models as _candidate_forms_models # noqa: E402, F401

View File

@ -0,0 +1,50 @@
import uuid
from datetime import datetime, timezone
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def as_uuid(record_id) -> uuid.UUID | None:
"""Parse a UUID from any id-ish value; None when blank or malformed."""
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
def location_options(search=None) -> list[str]:
"""`{City} - {Country}` for every city in global_cities.Countries, optionally filtered."""
from global_cities import Countries
term = (search or "").strip().lower()
seen = set()
options = []
for country, cities in Countries.items():
for city in cities:
label = f"{city} - {country}"
if label in seen or (term and term not in label.lower()):
continue
seen.add(label)
options.append(label)
return options
def department_job_metrics(job_rows, applicants_by_job) -> dict:
"""Roll job-level rows up to {department_id: {job_posts, open_roles, candidates}}.
`job_rows` are (department_id, job_post_id, requisition_status); `applicants_by_job`
maps str(job_post_id) -> unique applicants on that job. Open roles are job posts
whose requisition_status is "open", the same meaning analytics uses.
"""
metrics = {}
for department_id, job_post_id, requisition_status in job_rows or []:
m = metrics.setdefault(department_id, {"job_posts": 0, "open_roles": 0, "candidates": 0})
m["job_posts"] += 1
if requisition_status == "open":
m["open_roles"] += 1
m["candidates"] += int(applicants_by_job.get(str(job_post_id), 0))
return metrics

View File

@ -0,0 +1,38 @@
from department.models import Department
def serialize_head_option(user) -> dict:
return {"id": str(user.id), "name": user.name, "email": user.email}
def serialize_department(
department: Department,
*,
head_name: str | None = None,
parent_name: str | None = None,
metrics: dict | None = None,
) -> dict:
return {
"id": str(department.id),
"name": department.name,
"short_code": department.short_code,
"subtitle": department.subtitle,
"description": department.description,
"is_active": department.is_active,
"parent_department_id": (
str(department.parent_department_id) if department.parent_department_id else None
),
"parent_department_name": parent_name,
"department_head_id": (
str(department.department_head_id) if department.department_head_id else None
),
"department_head_name": head_name,
"location": list(department.location or []),
"job_posts": (metrics or {}).get("job_posts", 0),
"open_roles": (metrics or {}).get("open_roles", 0),
"candidates": (metrics or {}).get("candidates", 0),
"created_by": str(department.created_by) if department.created_by else None,
"updated_by": str(department.updated_by) if department.updated_by else None,
"created_at": department.created_at.isoformat() if department.created_at else None,
"updated_at": department.updated_at.isoformat() if department.updated_at else None,
}

View File

@ -0,0 +1,77 @@
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from department.models import Department
from department.plugins import as_uuid,department_job_metrics,location_options
from department.serializers import serialize_department,serialize_head_option
from job.job_post.models import JobPosts
from users.models import Users
def serialize_department_name(row):
return {"id":str(row.id),"name":row.name}
class DepartmentService:
def __init__(self,session:AsyncSession):
self.session=session
async def get_department_names(self,search):
rows=await Department.get_department_names(self.session,search)
return [serialize_department_name(row) for row in rows]
async def _serialize_many(self,departments):
head_names=await Users.names_by_ids(self.session,[d.department_head_id for d in departments])
parent_names=await Department.names_by_ids(self.session,[d.parent_department_id for d in departments])
job_rows=await Department.job_posts_for(self.session,[d.id for d in departments])
job_ids=list({str(r[1]) for r in job_rows})
applicants={}
if job_ids:
stats,_=await JobPosts.fetch_job_stats(self.session,ids=job_ids)
applicants={str(row["job_post_id"]):row["total_applicants"] for row in stats}
metrics=department_job_metrics(job_rows,applicants)
return [
serialize_department(
d,
head_name=head_names.get(str(d.department_head_id)),
parent_name=parent_names.get(d.parent_department_id),
metrics=metrics.get(d.id),
)
for d in departments
]
async def get_department(self,record_id):
department=await Department.get_by_id(self.session,record_id)
if not department:
raise HTTPException(status_code=404,detail="Department not found")
return (await self._serialize_many([department]))[0]
async def get_departments(self,top,skip,search,is_active):
departments=await Department.get_departments(self.session,top,skip,search,is_active)
total=await Department.count_departments(self.session,search,is_active)
return await self._serialize_many(departments),total
async def create_department(self,payload,user_id):
actor=as_uuid(user_id)
fields={**payload,"created_by":actor,"updated_by":actor}
try:
department=await Department.insert_department(self.session,fields)
except IntegrityError as e:
raise HTTPException(status_code=409,detail=str(e.orig)) from e
return (await self._serialize_many([department]))[0]
async def update_department(self,record_id,payload,user_id):
fields={**payload,"updated_by":as_uuid(user_id)}
try:
department=await Department.update_department(self.session,record_id,fields)
except IntegrityError as e:
raise HTTPException(status_code=409,detail=str(e.orig)) from e
if not department:
raise HTTPException(status_code=404,detail="Department not found")
return (await self._serialize_many([department]))[0]
async def get_head_options(self,role_id,top,skip,search):
rows=await Users.get_users(self.session,top,skip,search,role_id)
return [serialize_head_option(row) for row in rows]
async def get_location_options(self,search):
return location_options(search)

View File

@ -11,6 +11,7 @@ from dotenv import load_dotenv
from taskiq import TaskiqEvents
from db_setup import session_scope
import department.models # noqa: F401
from inbox.models import MailboxSyncRun
from inbox.views import Email
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY

View File

@ -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."""

View File

@ -143,6 +143,7 @@ class HiringCostCreate(BaseModel):
class JobUpdate(BaseModel):
title: str | None = None
department: str | None = None
department_id: UUID | None = None
location: str | None = None
employment_type: str | None = None
vacancies: int | None = None
@ -152,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
@ -937,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
@ -983,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),

View File

@ -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",
}

View File

@ -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

View File

@ -12,6 +12,7 @@ from job.job_post.enums import RequisitionStatus
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
from candidate_forms.models import Requisition
from department.models import Department
from users.models import Users
@ -25,6 +26,11 @@ class JobPosts(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
title: str = Field(index=True)
# Many job posts -> one department. `department` (below) stays the free-text name that
# analytics / filters / talent pool key off; set both together (see JobPost views).
# The relationship is `department_ref` because `department` is already that column.
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id", index=True)
department_ref: Optional["Department"] = Relationship(back_populates="job_posts", sa_relationship_kwargs={"lazy": "selectin"})
user: Optional["Users"] = Relationship(
back_populates="job_posts",
@ -54,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": "[]"},
)
@ -88,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
@ -501,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"),
@ -511,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"),
@ -530,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:
@ -885,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())
@ -985,3 +968,6 @@ class SocialPlatform(SQLModel, table=True):
# configure — JobPosts.requisition_id FKs to app.requisitions.
import candidate_forms.models as _requisition_models # noqa: E402, F401
import users.models as _users_models # noqa: E402, F401
# JobPosts.department_ref resolves "Department" at mapper configure; workers that
# never import the department app would otherwise fail to map JobPosts.
import department.models as _department_models # noqa: E402, F401

View File

@ -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 {},
}

View File

@ -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],
}
@ -41,6 +46,7 @@ def serialize_job_post(row, *, names=None) -> dict:
"title": row.title,
# Talent Pool / candidate filters key off attached job_posts.department.
"department": row.department or None,
"department_id": str(row.department_id) if row.department_id else None,
"employment_type": row.employment_type,
"location": row.location,
"experience_min": row.experience_min,
@ -68,27 +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 or 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,
@ -99,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,
@ -110,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,
}
@ -155,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,
}

View File

@ -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,
}
@ -83,6 +83,7 @@ class JobPostCreate(BaseModel):
location: str | None = None
employment_type: str | None = None
department: str | None = None
department_id: UUID | None = None
vacancies: int = 1
description: str | None = None
platform: str | None = None
@ -92,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
@ -181,6 +181,7 @@ class JobPost:
"salary":payload.get("salary") or "Anonymous",
# department is NOT NULL with a server_default of "" — pass "", never None.
"department":payload.get("department") or "",
"department_id":None,
"vacancies":payload.get("vacancies") or 1,
"description":payload.get("description"),
"post_text":text,
@ -191,6 +192,10 @@ class JobPost:
# Only set platform when it is actually known: passing None would override the
# column default and break the NOT NULL constraint. Buffer's channelService
# replaces this with the authoritative value once the post is created.
if payload.get("department_id"):
department=await self._require_department(payload.get("department_id"))
fields["department_id"]=department.id
fields["department"]=department.name
known_platform=service or normalize_platform(payload.get("platform"),aliases)
if known_platform:
fields["platform"]=known_platform
@ -370,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
@ -398,31 +403,87 @@ 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
department=await Department.get_by_id(self.session,department_id)
if not department:
raise HTTPException(status_code=404,detail="Department not found")
return department
async def update_job(self,job_post_id,payload,current_user):
if not current_user:
@ -444,6 +505,16 @@ class JobPost:
fields["salary"]=str(high)
if "department" in fields and fields["department"] is None:
fields["department"]=""
if "department_id" in payload:
if payload.get("department_id"):
department=await self._require_department(payload.get("department_id"))
fields["department_id"]=department.id
fields["department_ref"]=department
fields["department"]=department.name
else:
fields["department_id"]=None
fields["department_ref"]=None
fields["department"]=""
assignment=Assignment(self.session)
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None

View File

@ -24,6 +24,8 @@ from talent.app import router as talent_router
from candidate_forms.app import router as candidate_forms_router
from g_sheet.app import router as g_sheet_router
from s3.app import router as s3_router
from department.app import router as department_router
from verification_check.app import router as verification_check_router
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
logger=logging.getLogger("main")
@ -136,3 +138,5 @@ app.include_router(talent_router)
app.include_router(candidate_forms_router)
app.include_router(g_sheet_router)
app.include_router(s3_router)
app.include_router(department_router)
app.include_router(verification_check_router)

View File

@ -0,0 +1,92 @@
-- 038_departments.sql
-- Departments as a managed entity (backend/department/models.py): name, short
-- code, subtitle (replaces the design's "Cost Center"), description, status,
-- head, parent department and region/location list. Plus the `department`
-- permission module (8 tags), a `department_management` bundle holding them,
-- and that bundle attached to the admin roles.
--
-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql()
-- and recorded in manual_migrations. Needed because prod boots with
-- DB_AUTOGENERATE=false and never autogenerates new tables. Index names match
-- the db_setup NAMING_CONVENTION so a dev DB that autogenerated first is a no-op.
-- Users must log in again afterwards — the frontend caches /users/me permissions.
-- =============================================================================
-- 1. Table
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.departments (
id uuid PRIMARY KEY,
name varchar NOT NULL,
short_code varchar(10) NOT NULL,
subtitle varchar,
description varchar,
is_active boolean NOT NULL DEFAULT true,
parent_department_id uuid REFERENCES app.departments(id),
department_head_id uuid REFERENCES app.users(id),
location jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
created_by uuid REFERENCES app.users(id),
updated_by uuid REFERENCES app.users(id)
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_departments_name
ON app.departments (name);
CREATE UNIQUE INDEX IF NOT EXISTS ix_departments_short_code
ON app.departments (short_code);
CREATE INDEX IF NOT EXISTS ix_departments_parent_department_id
ON app.departments (parent_department_id);
-- =============================================================================
-- 2. The 8 department.* permission tags
-- =============================================================================
INSERT INTO app.permission_tags
(tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
VALUES
('department.view', 'department', 'view', NULL, NOW(), NOW(), true, false),
('department.create', 'department', 'create', NULL, NOW(), NOW(), true, false),
('department.edit', 'department', 'edit', NULL, NOW(), NOW(), true, false),
('department.delete', 'department', 'delete', NULL, NOW(), NOW(), true, false),
('department.approve', 'department', 'approve', NULL, NOW(), NOW(), true, false),
('department.export', 'department', 'export', NULL, NOW(), NOW(), true, false),
('department.manage', 'department', 'manage', NULL, NOW(), NOW(), true, false),
('department.configure', 'department', 'configure', NULL, NOW(), NOW(), true, false)
ON CONFLICT (tag_name) DO NOTHING;
-- =============================================================================
-- 3. Bundle holding all eight department tags
-- =============================================================================
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
SELECT
'department_management',
'Departments: view, create, edit and manage departments',
(
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
FROM app.permission_tags
WHERE is_deleted = false
AND module = 'department'
),
true,
NOW(),
NOW(),
true,
false
WHERE NOT EXISTS (
SELECT 1 FROM app.permissions WHERE name = 'department_management'
);
-- =============================================================================
-- 4. Attach the bundle to the admin roles (idempotent)
-- =============================================================================
UPDATE app.roles r
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
updated_at = NOW()
FROM app.permissions p
WHERE p.name = 'department_management'
AND r.role_name IN (
'system_administrator',
'hr_administrator'
)
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));

View File

@ -0,0 +1,50 @@
-- 039_requisition_department_id.sql
-- Many requisitions -> one department. Adds requisitions.department_id, the FK
-- behind Requisition.department_id / Requisition.department and
-- Department.requisitions (backend/candidate_forms/models.py,
-- backend/department/models.py).
--
-- A new file, not an edit to 020 or 038: manual migrations run once and are
-- recorded in manual_migrations, so changes to an applied file never reach an
-- existing database. Applied at startup by alembic_setup.run_manual_sql()
-- after 038, so app.departments already exists.
--
-- The legacy free-text requisitions.department column is kept and only read
-- here to backfill: rows whose text equals a department's name or short code
-- (case-insensitive, trimmed) get that department's id. Unmatched rows stay
-- NULL. Drop the text column in a later migration once nothing reads it.
ALTER TABLE app.requisitions
ADD COLUMN IF NOT EXISTS department_id uuid;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_requisitions_department_id_departments'
) THEN
ALTER TABLE app.requisitions
ADD CONSTRAINT fk_requisitions_department_id_departments
FOREIGN KEY (department_id) REFERENCES app.departments (id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS ix_requisitions_department_id
ON app.requisitions (department_id);
-- Backfill from the legacy text column, if it is still there.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'requisitions'
AND column_name = 'department'
) THEN
UPDATE app.requisitions r
SET department_id = d.id
FROM app.departments d
WHERE r.department_id IS NULL
AND lower(btrim(r.department)) IN (lower(d.name), lower(d.short_code));
END IF;
END $$;

View File

@ -0,0 +1,63 @@
-- 040_job_post_department_id.sql
-- Move the department link from requisitions to job posts: many job posts -> one
-- department. Reverses 039 (requisitions.department_id) and adds
-- job_posts.department_id, the FK behind JobPosts.department_id /
-- JobPosts.department_ref and Department.job_posts.
--
-- job_posts.department (free text) stays: analytics, filters and the talent pool
-- key off it, and the app now writes the department's name there whenever
-- department_id is set.
--
-- Idempotent; applied at startup by alembic_setup.run_manual_sql() after 039.
-- =============================================================================
-- 1. requisitions: drop the 039 link, keeping the department name as text
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app' AND table_name = 'requisitions' AND column_name = 'department_id'
) THEN
-- Rows created while the link existed wrote only department_id; carry the name back.
UPDATE app.requisitions r
SET department = d.name
FROM app.departments d
WHERE r.department_id = d.id
AND (r.department IS NULL OR btrim(r.department) = '');
ALTER TABLE app.requisitions
DROP CONSTRAINT IF EXISTS fk_requisitions_department_id_departments;
DROP INDEX IF EXISTS app.ix_requisitions_department_id;
ALTER TABLE app.requisitions DROP COLUMN department_id;
END IF;
END $$;
-- =============================================================================
-- 2. job_posts.department_id -> departments.id
-- =============================================================================
ALTER TABLE app.job_posts
ADD COLUMN IF NOT EXISTS department_id uuid;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_job_posts_department_id_departments'
) THEN
ALTER TABLE app.job_posts
ADD CONSTRAINT fk_job_posts_department_id_departments
FOREIGN KEY (department_id) REFERENCES app.departments (id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS ix_job_posts_department_id
ON app.job_posts (department_id);
-- Backfill: job posts whose text department equals a department's name or short
-- code (case-insensitive, trimmed). Unmatched rows stay NULL.
UPDATE app.job_posts j
SET department_id = d.id
FROM app.departments d
WHERE j.department_id IS NULL
AND lower(btrim(j.department)) IN (lower(d.name), lower(d.short_code));

View File

@ -0,0 +1,53 @@
-- 041_requisition_department_id.sql
-- Restores requisitions.department_id: many requisitions -> one department. This
-- is the FK behind Requisition.department_id / Requisition.department_ref
-- (backend/candidate_forms/models.py) and Department.requisitions
-- (backend/department/models.py). department_ref/requisitions are ORM
-- relationships only — they add no column, the FK below is the whole schema
-- change, and a requisition is linked by writing department_id.
--
-- 039 added this column and 040 dropped it again when the department link moved
-- to job_posts; job_posts.department_id from 040 stays. A new file rather than
-- an edit to either: manual migrations run once and are recorded in
-- manual_migrations, so edits to an applied file never reach an existing
-- database. Applied at startup by alembic_setup.run_manual_sql().
--
-- The legacy free-text requisitions.department column is kept and only read here
-- to backfill; 040 wrote the department name back into it before dropping the
-- FK, so rows linked under 039 recover their link. Unmatched rows stay NULL.
ALTER TABLE app.requisitions
ADD COLUMN IF NOT EXISTS department_id uuid;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_requisitions_department_id_departments'
) THEN
ALTER TABLE app.requisitions
ADD CONSTRAINT fk_requisitions_department_id_departments
FOREIGN KEY (department_id) REFERENCES app.departments (id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS ix_requisitions_department_id
ON app.requisitions (department_id);
-- Backfill from the legacy text column, if it is still there: match a
-- department's name or short code, case-insensitive and trimmed.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'requisitions'
AND column_name = 'department'
) THEN
UPDATE app.requisitions r
SET department_id = d.id
FROM app.departments d
WHERE r.department_id IS NULL
AND lower(btrim(r.department)) IN (lower(d.name), lower(d.short_code));
END IF;
END $$;

View File

@ -0,0 +1,73 @@
-- 042_verification_check.sql
-- Annexure E - Employment Verification Check Form as its own table
-- (backend/verification_check/models.py, VerificationCheck). One row per referee
-- call, so a candidate can have several. recruiter_id and candidate_id both
-- point at users.id (recruiters and candidates are users in this system);
-- created_by is the logged-in user who filled the form.
--
-- The table name is mixed case at the user's request, so it must stay quoted
-- ("Verification_Check") in any raw SQL. Index / FK names follow the db_setup
-- NAMING_CONVENTION so a dev DB that autogenerated first is a no-op.
--
-- Idempotent, applied at startup by alembic_setup.run_manual_sql(). Needed
-- because prod boots with DB_AUTOGENERATE=false and never autogenerates tables.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'rehireeligibility' AND n.nspname = 'app'
) THEN
CREATE TYPE app.rehireeligibility AS ENUM ('yes', 'no', 'conditional');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS app."Verification_Check" (
id uuid PRIMARY KEY,
recruiter_id uuid,
candidate_id uuid,
position_applied varchar,
department_id uuid,
department varchar,
date_time timestamptz,
confirmed_job_title varchar,
confirmed_from date,
confirmed_to date,
reason_for_leaving varchar,
technical_knowledge_rating integer,
technical_knowledge_notes varchar,
reliability_rating integer,
reliability_notes varchar,
communication_rating integer,
communication_notes varchar,
problem_solving_rating integer,
problem_solving_notes varchar,
strengths varchar,
areas_for_growth varchar,
rehire_eligibility app.rehireeligibility,
rehire_comments varchar,
referee_full_name varchar,
referee_current_title varchar,
referee_company varchar,
referee_phone varchar,
referee_email varchar,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
is_deleted boolean NOT NULL DEFAULT false,
CONSTRAINT "fk_Verification_Check_recruiter_id_users"
FOREIGN KEY (recruiter_id) REFERENCES app.users (id),
CONSTRAINT "fk_Verification_Check_candidate_id_users"
FOREIGN KEY (candidate_id) REFERENCES app.users (id),
CONSTRAINT "fk_Verification_Check_department_id_departments"
FOREIGN KEY (department_id) REFERENCES app.departments (id),
CONSTRAINT "fk_Verification_Check_created_by_users"
FOREIGN KEY (created_by) REFERENCES app.users (id)
);
CREATE INDEX IF NOT EXISTS "ix_Verification_Check_recruiter_id"
ON app."Verification_Check" (recruiter_id);
CREATE INDEX IF NOT EXISTS "ix_Verification_Check_candidate_id"
ON app."Verification_Check" (candidate_id);

View File

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

View File

@ -208,17 +208,10 @@ async def system_admin_ids(session):
async def job_recruiter_ids(session, job):
"""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:

View File

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

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

View File

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

View File

@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from 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
@ -31,11 +31,6 @@ class Users(SQLModel, table=True):
role: Roles | None = Relationship(back_populates="users",
sa_relationship_kwargs={"lazy": "selectin"}
)
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
# user row once per post. Without an explicit strategy the default is a lazy load,
# which raises MissingGreenlet the moment anything touches it under asyncio.
# foreign_keys must match the other side: job_posts also has current_recruiter_id
# and hiring_manager_id into this table, so this relation has to say created_by.
job_posts: List[JobPosts] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
@ -159,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
@ -169,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."""

View File

@ -30,6 +30,7 @@ class PermissionModule(str, Enum):
JOBS = "jobs"
CANDIDATES = "candidates"
PIPELINE = "pipeline"
DEPARTMENT = "department"
INTERVIEWS = "interviews"
ASSESSMENTS = "assessments"
OFFERS = "offers"
@ -71,6 +72,16 @@ class PermissionTag(str, Enum):
DASHBOARD_EXPORT = "dashboard.export"
DASHBOARD_MANAGE = "dashboard.manage"
DASHBOARD_CONFIGURE = "dashboard.configure"
DEPARTMENT_VIEW = "department.view"
DEPARTMENT_CREATE = "department.create"
DEPARTMENT_EDIT = "department.edit"
DEPARTMENT_DELETE = "department.delete"
DEPARTMENT_APPROVE = "department.approve"
DEPARTMENT_EXPORT = "department.export"
DEPARTMENT_MANAGE = "department.manage"
DEPARTMENT_CONFIGURE = "department.configure"
INBOX_VIEW = "inbox.view"
INBOX_CREATE = "inbox.create"
INBOX_EDIT = "inbox.edit"

View File

@ -0,0 +1,108 @@
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from db_setup import get_session
from users.permissions import PermissionTag, require_permission
from verification_check.enums import (
CompetencyEvaluation,
EmploymentVerification,
QualitativeEvaluation,
RecordOverview,
RefereeDetails,
)
from verification_check.views import VerificationCheckForm
router = APIRouter()
class VerificationCheckCreate(BaseModel):
recruiter_id:uuid.UUID
candidate_id:uuid.UUID
record_overview:Optional[RecordOverview]
employment_verification:Optional[EmploymentVerification]
competency_evaluation:Optional[CompetencyEvaluation]
qualitative_evaluation:Optional[QualitativeEvaluation]
referee:Optional[RefereeDetails]
class VerificationCheckUpdate(BaseModel):
recruiter_id:Optional[uuid.UUID]=None
candidate_id:Optional[uuid.UUID]=None
record_overview:Optional[RecordOverview]=None
employment_verification:Optional[EmploymentVerification]=None
competency_evaluation:Optional[CompetencyEvaluation]=None
qualitative_evaluation:Optional[QualitativeEvaluation]=None
referee:Optional[RefereeDetails]=None
@router.get("/verification-check/fetch")
async def fetch_verification_check(
current_user:dict=Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
form_id:str=Query(None),
candidate_id:str=Query(None),
session:AsyncSession=Depends(get_session),
):
"""One row by form_id, else the list (optionally one candidate's referees).
Admins see every row; other roles stay scoped to created_by."""
try:
service=VerificationCheckForm(session=session)
data=await service.get_form_by_id(form_id,candidate_id,current_user)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/verification-check/create")
async def create_verification_check(
payload: VerificationCheckCreate,
current_user:dict=Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session:AsyncSession=Depends(get_session),
):
try:
service = VerificationCheckForm(session=session)
data = await service.create_form(payload.model_dump(exclude_unset=True), current_user)
return JSONResponse(content={"data": data, "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/verification-check/update")
async def update_verification_check(
payload: VerificationCheckUpdate,
current_user:dict=Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
form_id:str=Query(...),
session:AsyncSession=Depends(get_session),
):
try:
service=VerificationCheckForm(session=session)
data=await service.update_form(form_id,payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.delete("/verification-check/delete")
async def delete_verification_check(
current_user:dict=Depends(require_permission(PermissionTag.CANDIDATES_DELETE)),
form_id:str=Query(...),
session:AsyncSession=Depends(get_session),
):
try:
service=VerificationCheckForm(session=session)
data=await service.delete_form(form_id,current_user)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))

View File

@ -0,0 +1,64 @@
from enum import Enum
from pydantic import BaseModel
from typing import Optional
from datetime import date, datetime
import uuid
class CompetencyRating(int, Enum):
NEEDS_IMPROVEMENT = 1
SATISFACTORY = 2
GOOD = 3
EXCELLENT = 4
class ReHireEligibility(str, Enum):
YES = "yes"
NO = "no"
CONDITIONAL = "conditional"
# SECTION 1: INFORMATION & RECORD OVERVIEW
class RecordOverview(BaseModel):
position_applied:Optional[str]=None
department_id:Optional[uuid.UUID]=None
department:Optional[str]=None
date_time:Optional[datetime]=None
# SECTION 2: EMPLOYMENT VERIFICATION & DUTIES
class EmploymentVerification(BaseModel):
confirmed_job_title:Optional[str]=None
confirmed_from:Optional[date]=None
confirmed_to:Optional[date]=None
reason_for_leaving:Optional[str]=None
# SECTION 3: one row of the competency grid (1-4 tick + Notes column)
class CompetencyScore(BaseModel):
rating:Optional[CompetencyRating]=None
notes:Optional[str]=None
class CompetencyEvaluation(BaseModel):
technical_knowledge:Optional[CompetencyScore]=None
reliability:Optional[CompetencyScore]=None
communication:Optional[CompetencyScore]=None
problem_solving:Optional[CompetencyScore]=None
# SECTION 4: QUALITATIVE EVALUATION & KEY INSIGHTS
class QualitativeEvaluation(BaseModel):
strengths:Optional[str]=None
areas_for_growth:Optional[str]=None
rehire_eligibility:Optional[ReHireEligibility]=None
rehire_comments:Optional[str]=None
# SECTION 5: REFEREE CONTACT & RELATIONSHIP VERIFICATION
class RefereeDetails(BaseModel):
full_name:Optional[str]=None
current_title:Optional[str]=None
company:Optional[str]=None
phone:Optional[str]=None
email:Optional[str]=None

View File

@ -0,0 +1,229 @@
import uuid
from datetime import datetime, date as Date, timezone
from typing import Optional
from sqlalchemy import DateTime, Enum as SAEnum
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
from verification_check.enums import ReHireEligibility
def _now() -> datetime:
return datetime.now(timezone.utc)
class VerificationCheck(SQLModel, table=True):
"""Annexure E - Employment Verification Check Form. One row per referee call,
so a candidate can hold several. recruiter_id / candidate_id are both users.id."""
__tablename__ = "Verification_Check"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
recruiter_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id", index=True)
candidate_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id", index=True)
position_applied: Optional[str] = None
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id")
department: Optional[str] = None
date_time: Optional[datetime] = Field(default=None, sa_type=DateTime(timezone=True))
confirmed_job_title: Optional[str] = None
confirmed_from: Optional[Date] = None
confirmed_to: Optional[Date] = None
reason_for_leaving: Optional[str] = None
technical_knowledge_rating: Optional[int] = None
technical_knowledge_notes: Optional[str] = None
reliability_rating: Optional[int] = None
reliability_notes: Optional[str] = None
communication_rating: Optional[int] = None
communication_notes: Optional[str] = None
problem_solving_rating: Optional[int] = None
problem_solving_notes: Optional[str] = None
strengths: Optional[str] = None
areas_for_growth: Optional[str] = None
rehire_eligibility: Optional[ReHireEligibility] = Field(
default=None,
sa_type=SAEnum(
ReHireEligibility,
name="rehireeligibility",
schema="app",
native_enum=True,
values_callable=lambda enum: [member.value for member in enum],
),
)
rehire_comments: Optional[str] = None
referee_full_name: Optional[str] = None
referee_current_title: Optional[str] = None
referee_company: Optional[str] = None
referee_phone: Optional[str] = None
referee_email: Optional[str] = None
created_by: Optional[uuid.UUID] = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_deleted: bool = Field(default=False)
@classmethod
async def get_form_by_id(cls, session: AsyncSession, record_id=None, created_by=None, candidate_id=None):
qry = select(cls).where(cls.is_deleted == False) # noqa: E712
if created_by is not None:
qry = qry.where(cls.created_by == created_by)
if candidate_id is not None:
qry = qry.where(cls.candidate_id == candidate_id)
if record_id not in (None, ""):
try:
uid = uuid.UUID(str(record_id))
except (TypeError, ValueError):
return None
qry = qry.where(cls.id == uid)
result = await session.execute(qry)
return result.scalars().first()
result = await session.execute(qry.order_by(cls.created_at.desc(), cls.id.desc()))
return list(result.scalars().all())
@classmethod
async def insert_form(cls, session: AsyncSession, fields: dict):
overview = fields.get("record_overview") if fields.get("record_overview") else {}
employment = fields.get("employment_verification") if fields.get("employment_verification") else {}
competency = fields.get("competency_evaluation") if fields.get("competency_evaluation") else {}
technical = competency.get("technical_knowledge") if competency.get("technical_knowledge") else {}
reliability = competency.get("reliability") if competency.get("reliability") else {}
communication = competency.get("communication") if competency.get("communication") else {}
problem_solving = competency.get("problem_solving") if competency.get("problem_solving") else {}
qualitative = fields.get("qualitative_evaluation") if fields.get("qualitative_evaluation") else {}
referee = fields.get("referee") if fields.get("referee") else {}
row = cls(
recruiter_id=fields.get("recruiter_id") if fields.get("recruiter_id") else None,
candidate_id=fields.get("candidate_id") if fields.get("candidate_id") else None,
position_applied=overview.get("position_applied") if overview.get("position_applied") else None,
department_id=overview.get("department_id") if overview.get("department_id") else None,
department=overview.get("department") if overview.get("department") else None,
date_time=overview.get("date_time") if overview.get("date_time") else None,
confirmed_job_title=employment.get("confirmed_job_title") if employment.get("confirmed_job_title") else None,
confirmed_from=employment.get("confirmed_from") if employment.get("confirmed_from") else None,
confirmed_to=employment.get("confirmed_to") if employment.get("confirmed_to") else None,
reason_for_leaving=employment.get("reason_for_leaving") if employment.get("reason_for_leaving") else None,
technical_knowledge_rating=int(technical.get("rating")) if technical.get("rating") else None,
technical_knowledge_notes=technical.get("notes") if technical.get("notes") else None,
reliability_rating=int(reliability.get("rating")) if reliability.get("rating") else None,
reliability_notes=reliability.get("notes") if reliability.get("notes") else None,
communication_rating=int(communication.get("rating")) if communication.get("rating") else None,
communication_notes=communication.get("notes") if communication.get("notes") else None,
problem_solving_rating=int(problem_solving.get("rating")) if problem_solving.get("rating") else None,
problem_solving_notes=problem_solving.get("notes") if problem_solving.get("notes") else None,
strengths=qualitative.get("strengths") if qualitative.get("strengths") else None,
areas_for_growth=qualitative.get("areas_for_growth") if qualitative.get("areas_for_growth") else None,
rehire_eligibility=ReHireEligibility(qualitative.get("rehire_eligibility")) if qualitative.get("rehire_eligibility") else None,
rehire_comments=qualitative.get("rehire_comments") if qualitative.get("rehire_comments") else None,
referee_full_name=referee.get("full_name") if referee.get("full_name") else None,
referee_current_title=referee.get("current_title") if referee.get("current_title") else None,
referee_company=referee.get("company") if referee.get("company") else None,
referee_phone=referee.get("phone") if referee.get("phone") else None,
referee_email=referee.get("email") if referee.get("email") else None,
created_by=fields.get("created_by") if fields.get("created_by") else None,
)
session.add(row)
await session.commit()
return await cls.get_form_by_id(session, row.id)
@classmethod
async def update_form(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_form_by_id(session, record_id)
if not row:
return None
if "recruiter_id" in fields:
row.recruiter_id = fields.get("recruiter_id") if fields.get("recruiter_id") else None
if "candidate_id" in fields:
row.candidate_id = fields.get("candidate_id") if fields.get("candidate_id") else None
if "record_overview" in fields:
overview = fields.get("record_overview") if fields.get("record_overview") else {}
if "position_applied" in overview:
row.position_applied = overview.get("position_applied") if overview.get("position_applied") else None
if "department_id" in overview:
row.department_id = overview.get("department_id") if overview.get("department_id") else None
if "department" in overview:
row.department = overview.get("department") if overview.get("department") else None
if "date_time" in overview:
row.date_time = overview.get("date_time") if overview.get("date_time") else None
if "employment_verification" in fields:
employment = fields.get("employment_verification") if fields.get("employment_verification") else {}
if "confirmed_job_title" in employment:
row.confirmed_job_title = employment.get("confirmed_job_title") if employment.get("confirmed_job_title") else None
if "confirmed_from" in employment:
row.confirmed_from = employment.get("confirmed_from") if employment.get("confirmed_from") else None
if "confirmed_to" in employment:
row.confirmed_to = employment.get("confirmed_to") if employment.get("confirmed_to") else None
if "reason_for_leaving" in employment:
row.reason_for_leaving = employment.get("reason_for_leaving") if employment.get("reason_for_leaving") else None
if "competency_evaluation" in fields:
competency = fields.get("competency_evaluation") if fields.get("competency_evaluation") else {}
if "technical_knowledge" in competency:
technical = competency.get("technical_knowledge") if competency.get("technical_knowledge") else {}
if "rating" in technical:
row.technical_knowledge_rating = int(technical.get("rating")) if technical.get("rating") else None
if "notes" in technical:
row.technical_knowledge_notes = technical.get("notes") if technical.get("notes") else None
if "reliability" in competency:
reliability = competency.get("reliability") if competency.get("reliability") else {}
if "rating" in reliability:
row.reliability_rating = int(reliability.get("rating")) if reliability.get("rating") else None
if "notes" in reliability:
row.reliability_notes = reliability.get("notes") if reliability.get("notes") else None
if "communication" in competency:
communication = competency.get("communication") if competency.get("communication") else {}
if "rating" in communication:
row.communication_rating = int(communication.get("rating")) if communication.get("rating") else None
if "notes" in communication:
row.communication_notes = communication.get("notes") if communication.get("notes") else None
if "problem_solving" in competency:
problem_solving = competency.get("problem_solving") if competency.get("problem_solving") else {}
if "rating" in problem_solving:
row.problem_solving_rating = int(problem_solving.get("rating")) if problem_solving.get("rating") else None
if "notes" in problem_solving:
row.problem_solving_notes = problem_solving.get("notes") if problem_solving.get("notes") else None
if "qualitative_evaluation" in fields:
qualitative = fields.get("qualitative_evaluation") if fields.get("qualitative_evaluation") else {}
if "strengths" in qualitative:
row.strengths = qualitative.get("strengths") if qualitative.get("strengths") else None
if "areas_for_growth" in qualitative:
row.areas_for_growth = qualitative.get("areas_for_growth") if qualitative.get("areas_for_growth") else None
if "rehire_eligibility" in qualitative:
row.rehire_eligibility = ReHireEligibility(qualitative.get("rehire_eligibility")) if qualitative.get("rehire_eligibility") else None
if "rehire_comments" in qualitative:
row.rehire_comments = qualitative.get("rehire_comments") if qualitative.get("rehire_comments") else None
if "referee" in fields:
referee = fields.get("referee") if fields.get("referee") else {}
if "full_name" in referee:
row.referee_full_name = referee.get("full_name") if referee.get("full_name") else None
if "current_title" in referee:
row.referee_current_title = referee.get("current_title") if referee.get("current_title") else None
if "company" in referee:
row.referee_company = referee.get("company") if referee.get("company") else None
if "phone" in referee:
row.referee_phone = referee.get("phone") if referee.get("phone") else None
if "email" in referee:
row.referee_email = referee.get("email") if referee.get("email") else None
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def soft_delete_form(cls, session: AsyncSession, record_id):
row = await cls.get_form_by_id(session, record_id)
if not row:
return None
row.is_deleted = True
row.updated_at = _now()
session.add(row)
await session.commit()
return row
import users.models as _users_models # noqa: E402, F401
import department.models as _department_models # noqa: E402, F401

View File

@ -0,0 +1,68 @@
def _date(value):
return value.isoformat() if value else None
def _enum(value):
if value is None:
return None
return getattr(value, "value", value)
def serialize_verification_check(row, names=None) -> dict:
"""`names` is {user_id: name} from one batched Users.names_by_ids lookup in
views, so the recruiter / candidate search dropdowns can preselect on edit."""
names = names or {}
return {
"id": str(row.id) if row.id else None,
"recruiter_id": str(row.recruiter_id) if row.recruiter_id else None,
"recruiter_name": names.get(str(row.recruiter_id)) if row.recruiter_id else None,
"candidate_id": str(row.candidate_id) if row.candidate_id else None,
"candidate_name": names.get(str(row.candidate_id)) if row.candidate_id else None,
"record_overview": {
"position_applied": row.position_applied,
"department_id": str(row.department_id) if row.department_id else None,
"department": row.department,
"date_time": _date(row.date_time),
},
"employment_verification": {
"confirmed_job_title": row.confirmed_job_title,
"confirmed_from": _date(row.confirmed_from),
"confirmed_to": _date(row.confirmed_to),
"reason_for_leaving": row.reason_for_leaving,
},
"competency_evaluation": {
"technical_knowledge": {
"rating": row.technical_knowledge_rating,
"notes": row.technical_knowledge_notes,
},
"reliability": {
"rating": row.reliability_rating,
"notes": row.reliability_notes,
},
"communication": {
"rating": row.communication_rating,
"notes": row.communication_notes,
},
"problem_solving": {
"rating": row.problem_solving_rating,
"notes": row.problem_solving_notes,
},
},
"qualitative_evaluation": {
"strengths": row.strengths,
"areas_for_growth": row.areas_for_growth,
"rehire_eligibility": _enum(row.rehire_eligibility),
"rehire_comments": row.rehire_comments,
},
"referee": {
"full_name": row.referee_full_name,
"current_title": row.referee_current_title,
"company": row.referee_company,
"phone": row.referee_phone,
"email": row.referee_email,
},
"created_by": str(row.created_by) if row.created_by else None,
"created_by_name": names.get(str(row.created_by)) if row.created_by else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}

View File

@ -0,0 +1,60 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from candidate_forms.plugins import _as_uuid, _aware, _user_id
from users.models import Users
from users.permissions import is_admin
from verification_check.models import VerificationCheck
from verification_check.serializers import serialize_verification_check
class VerificationCheckForm:
def __init__(self, session: AsyncSession):
self.session = session
async def _serialize_rows(self, rows):
user_ids = set()
for row in rows:
user_ids |= {row.recruiter_id, row.candidate_id, row.created_by}
names = await Users.names_by_ids(self.session, user_ids)
return [serialize_verification_check(row, names) for row in rows]
async def create_form(self, payload, current_user):
if payload.get("record_overview") and "date_time" in payload["record_overview"]:
payload["record_overview"]["date_time"] = _aware(payload["record_overview"]["date_time"])
payload["created_by"] = _user_id(current_user)
row = await VerificationCheck.insert_form(self.session, payload)
return (await self._serialize_rows([row]))[0]
async def update_form(self, form_id, payload, current_user):
_user_id(current_user)
row = await VerificationCheck.get_form_by_id(self.session, form_id)
if not row:
raise HTTPException(status_code=404, detail="Verification check not found")
if not payload:
raise HTTPException(status_code=400, detail="No fields to update")
if payload.get("record_overview") and "date_time" in payload["record_overview"]:
payload["record_overview"]["date_time"] = _aware(payload["record_overview"]["date_time"])
updated = await VerificationCheck.update_form(self.session, form_id, payload)
if not updated:
raise HTTPException(status_code=404, detail="Verification check not found")
return (await self._serialize_rows([updated]))[0]
async def get_form_by_id(self, form_id, candidate_id, current_user):
created_by = None if is_admin(current_user) else _user_id(current_user)
if form_id:
row = await VerificationCheck.get_form_by_id(self.session, record_id=form_id, created_by=created_by)
if not row:
raise HTTPException(status_code=404, detail="Verification check not found")
return (await self._serialize_rows([row]))[0]
rows = await VerificationCheck.get_form_by_id(
self.session, created_by=created_by, candidate_id=_as_uuid(candidate_id)
)
return await self._serialize_rows(rows)
async def delete_form(self, form_id, current_user):
_user_id(current_user)
row = await VerificationCheck.soft_delete_form(self.session, form_id)
if not row:
raise HTTPException(status_code=404, detail="Verification check not found")
return {"id": str(row.id), "deleted": True}

View File

@ -0,0 +1,89 @@
#!/bin/bash
set -e
# Where CodeDeploy extracted the zip package
EXTRACT_DIR="/opt/codedeploy-extracted-utopia-ai-hr-ats-portal-dev"
# Dynamically set the true destination path based on the CodeDeploy group name
if [ -z "${DEPLOYMENT_GROUP_NAME}" ]; then
TARGET_DIR="utopia-ai-hr-ats-portal-dev-deployment-group"
else
TARGET_DIR="${DEPLOYMENT_GROUP_NAME}"
fi
FINAL_DIR="/home/ec2-user/${TARGET_DIR}"
echo "===== Moving files safely to ${FINAL_DIR} ====="
# Ensure the final folder exists (won't alter it if it does)
mkdir -p "${FINAL_DIR}"
# Copy everything from the extraction directory to your app folder
# This will overwrite code files, but leave your untracked .env safe and untouched!
cp -r "${EXTRACT_DIR}/." "${FINAL_DIR}/"
# Change into the actual app directory
cd "${FINAL_DIR}"
# Fix ownership so ec2-user owns the codebase
chown -R ec2-user:ec2-user "${FINAL_DIR}"
echo "--------- Checking/Installing Docker ---------"
if ! command -v docker &> /dev/null; then
echo "Installing Docker..."
dnf install docker -y
fi
systemctl start docker
systemctl enable docker
usermod -aG docker ec2-user
echo "Docker: $(docker --version)"
echo "--------- Installing Latest Docker Compose ---------"
mkdir -p /usr/libexec/docker/cli-plugins
curl -SL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" \
-o /usr/libexec/docker/cli-plugins/docker-compose
chmod +x /usr/libexec/docker/cli-plugins/docker-compose
echo "Docker Compose: $(docker compose version)"
echo "--------- Installing Latest Docker Buildx ---------"
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then
BUILDX_ARCH="amd64"
elif [ "$ARCH" = "aarch64" ]; then
BUILDX_ARCH="arm64"
else
BUILDX_ARCH="$ARCH"
fi
# Fetch latest release tag (e.g. v0.21.1)
BUILDX_TAG=$(curl -s https://api.github.com/repos/docker/buildx/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
echo "Downloading Buildx version ${BUILDX_TAG} for ${BUILDX_ARCH}..."
# Remove any existing broken binary before downloading
rm -f /usr/libexec/docker/cli-plugins/docker-buildx
curl -SL "https://github.com/docker/buildx/releases/download/${BUILDX_TAG}/buildx-${BUILDX_TAG}.linux-${BUILDX_ARCH}" \
-o /usr/libexec/docker/cli-plugins/docker-buildx
chmod +x /usr/libexec/docker/cli-plugins/docker-buildx
echo "Docker Buildx: $(docker buildx version)"
if [ ! -f "docker-compose.yml" ]; then
echo "ERROR: docker-compose.yml not found in ${FINAL_DIR}"
exit 1
fi
echo "--------- Stopping existing containers ---------"
docker compose down --remove-orphans || true
echo "--------- Building and starting containers ---------"
docker compose --env-file ./backend/.env build --no-cache
docker compose --env-file ./backend/.env up -d
echo "--------- Running containers ---------"
docker ps
# Optional: Clean up extraction directory to keep disk space clean
rm -rf "${EXTRACT_DIR:?}/*"
echo "===== Deployment completed successfully ====="

View File

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

View File

@ -8,6 +8,13 @@ server {
# CV / multipart uploads (MAX_PDF_SIZE_MB is 10; leave headroom for form fields).
client_max_body_size 25m;
# Resolve backend-api through Docker's embedded DNS on each request instead of
# once at startup. `docker compose up` recreates backend-api with a new IP;
# a static upstream keeps the old one and every API call 502s until nginx restarts.
# proxy_pass with a variable and no URI forwards the original request URI unchanged.
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend_api http://backend-api:8000;
# Security headers on every response.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
@ -23,7 +30,7 @@ server {
# SPA page roots that also prefix API calls sub-path required.
location ~ ^/(jobs|inbox|pipeline|tasks|assessments|offers|managers|analytics|notifications)/ {
proxy_pass http://backend-api:8000;
proxy_pass $backend_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@ -37,8 +44,8 @@ server {
}
# API-only prefixes (no SPA page at the bare path).
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms)(/|$) {
proxy_pass http://backend-api:8000;
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms|department)(/|$) {
proxy_pass $backend_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@ -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",

View File

@ -34,6 +34,7 @@ const SCREENS = {
assessments: lazy(() => import('./screens/Assessments')),
offers: lazy(() => import('./screens/Offers')),
managers: lazy(() => import('./screens/Managers')),
departments: lazy(() => import('./screens/Departments')),
calendar: lazy(() => import('./screens/Calendar')),
reports: lazy(() => import('./screens/Reports')),
analytics: lazy(() => import('./screens/Analytics')),
@ -54,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 (
@ -103,6 +105,14 @@ export default function App() {
</RequireAuth>
}
/>
<Route
path="/job/:jobId"
element={
<RequireAuth permission="jobs.view">
<JobProfile />
</RequireAuth>
}
/>
<Route
path="/candidates/:userId"
element={

View File

@ -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(

View File

@ -0,0 +1,51 @@
import { request } from '../lib/apiClient'
/* ============================================================
departments.js backend/department/app.py routes.
created_by / updated_by come from the JWT on the server do not send them.
`location` is a string[]; the form edits it as comma-separated text.
============================================================ */
export function list({ search, isActive, top, skip } = {}) {
return request('/department/fetch', {
params: { search: search || undefined, is_active: isActive, top, skip },
})
}
export function getById(recordId) {
return request('/department/fetch', { params: { record_id: recordId } })
}
export function create(body) {
return request('/department/create', { method: 'POST', body })
}
export function update(recordId, body) {
return request('/department/update', { method: 'PUT', params: { record_id: recordId }, body })
}
/** Department Head picker `{data:[{id,name,email}]}`. The server defaults
* `role_id` to the department_head role when it is not sent. */
export function listHeads({ roleId, search, top, skip } = {}) {
return request('/department/heads/fetch', {
params: { role_id: roleId, search: search || undefined, top, skip },
})
}
/** Region / Location picker — `{data:["Karachi - Pakistan", …]}` from global_cities.py. */
export function listLocations({ search } = {}) {
return request('/department/locations/fetch', { params: { search: search || undefined } })
}
/** Active departments for pickers (Requisitions "From (Dept.)") — `{data:[{id,name}]}`. */
export function listNames({ search } = {}) {
return request('/department/names', { params: { search: search || undefined } })
}
export function toRows(res) {
const data = res?.data
if (Array.isArray(data)) return data
if (data) return [data]
return []
}

View File

@ -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,14 +101,29 @@ 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,
department: row.department,
departmentId: row.department_id || null,
location: row.location,
type: row.employment_type,
vacancies: row.vacancies,
@ -100,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.
@ -177,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 } })

View File

@ -22,6 +22,11 @@ export function list() {
return request('/forms/requisition/fetch')
}
/** GET /forms/requisition/open-count — `{data:{open}}`: unlinked, or linked job still open. */
export function countOpen() {
return request('/forms/requisition/open-count')
}
export function getById(formId) {
return request('/forms/requisition/fetch', { params: { form_id: formId } })
}

View File

@ -44,6 +44,7 @@ export const ROUTES = [
{ path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' },
{ path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' },
{ path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: 'jobs.view' },
{ path: 'departments', title: 'Departments', icon: 'layers', group: 'Hiring', permission: 'department.view', tag: 'NEW' },
{ path: 'calendar', title: 'Calendar', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
// --- Insights ---

View File

@ -16,7 +16,7 @@
export const MODULES = [
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks',
'talent', 'requisitions',
'talent', 'requisitions', 'department',
]
export const ACTIONS = [

View File

@ -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: {
@ -143,9 +144,17 @@ export const qk = {
requisitions: {
all: () => ['requisitions'],
list: () => ['requisitions', 'list'],
openCount: () => ['requisitions', 'open-count'],
detail: (id) => ['requisitions', 'detail', id],
search: (q = '', jobPostId = null) => ['requisitions', 'search', q, jobPostId || null],
},
departments: {
all: () => ['departments'],
list: (p = {}) => ['departments', 'list', p],
heads: (p = {}) => ['departments', 'heads', p],
locations: () => ['departments', 'locations'],
names: (q = '') => ['departments', 'names', q],
},
interviews: {
all: () => ['interviews'],
range: (p = {}) => ['interviews', 'range', p],

View File

@ -0,0 +1,728 @@
/* ============================================================
Departments app.departments (backend/department/app.py).
List is GET /department/fetch, rendered as the card grid from the
"Departments — List" design. Create / edit share one modal from the
"Departments — Create" design, with the design's Cost Center field
replaced by Subtitle (the tagline under the name on each card).
============================================================ */
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Avatar, EmptyState, FieldError, Icon, KpiCard, 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 { exportStyledWorkbook } from '../lib/exportXlsx'
import * as departmentsApi from '../api/departments'
import * as requisitionsApi from '../api/requisitions'
// app.roles id of `department_head` the backend's default for /department/heads/fetch.
const DEPARTMENT_HEAD_ROLE_ID = 5
// Card icon tints cycle through the soft theme tokens so adjacent cards differ.
const TONES = ['success', 'primary', 'warning', 'purple', 'info']
function toneFor(id = '') {
let h = 0
for (let i = 0; i < id.length; i += 1) h = (h * 31 + id.charCodeAt(i)) >>> 0
return TONES[h % TONES.length]
}
/** KPI numbers — shared by the card row and the export's Summary sheet. */
function departmentStats(rows) {
const sum = (key) => rows.reduce((n, r) => n + (Number(r[key]) || 0), 0)
const jobPosts = sum('job_posts')
const openRoles = sum('open_roles')
return {
total: rows.length,
candidates: sum('candidates'),
openRoles,
jobPosts,
// Avg. open job posts = all open job posts / all job posts linked to departments.
openShareLabel: jobPosts ? `${Math.round((openRoles / jobPosts) * 100)}%` : '—',
}
}
/** Sheet 1: the KPI cards. Sheet 2: one row per department with everything the page shows. */
function buildExportSheets(rows, stats, exportedAt) {
const subtitle = `Exported ${exportedAt.toLocaleString()}`
const when = (iso) => (iso ? new Date(iso).toLocaleString() : '')
return [
{
name: 'Summary',
title: 'Departments — Summary',
subtitle,
columns: [
{ header: 'Metric', key: 'metric', width: 30 },
{ header: 'Value', key: 'value', width: 16 },
{ header: 'Notes', key: 'notes', width: 52 },
],
rows: [
{ metric: 'Total Departments', value: stats.total, notes: 'Across the organization' },
{ metric: 'Candidates Applied', value: stats.candidates, notes: 'Applicants on jobs linked to departments' },
{ metric: 'Open Requisitions', value: stats.openRequisitions ?? '—', notes: 'Not linked to a job post, or linked to a job post that is still open' },
{ metric: 'Avg. Open Job Posts', value: stats.openShareLabel, notes: `${stats.openRoles} open of ${stats.jobPosts} job posts` },
],
},
{
name: 'Departments',
title: 'Departments',
subtitle,
columns: [
{ header: 'Department', key: 'name', width: 28 },
{ header: 'Short Code', key: 'short_code', width: 12 },
{ header: 'Subtitle', key: 'subtitle', width: 26 },
{ header: 'Description', key: 'description', width: 44 },
{ header: 'Status', key: 'status', width: 10 },
{ header: 'Department Head', key: 'head', width: 22 },
{ header: 'Parent Department', key: 'parent', width: 22 },
{ header: 'Open Roles', key: 'open_roles', width: 12 },
{ header: 'Total Job Posts', key: 'job_posts', width: 14 },
{ header: 'Candidates', key: 'candidates', width: 12 },
{ header: 'Regions', key: 'region_count', width: 10 },
{ header: 'Region / Location', key: 'locations', width: 44 },
{ header: 'Created', key: 'created_at', width: 20 },
{ header: 'Updated', key: 'updated_at', width: 20 },
],
rows: rows.map((r) => ({
name: r.name,
short_code: r.short_code,
subtitle: r.subtitle || '',
description: r.description || '',
status: r.is_active ? 'Active' : 'Inactive',
head: r.department_head_name || '',
parent: r.parent_department_name || '',
open_roles: r.open_roles ?? 0,
job_posts: r.job_posts ?? 0,
candidates: r.candidates ?? 0,
region_count: (r.location || []).length,
locations: (r.location || []).join('; '),
created_at: when(r.created_at),
updated_at: when(r.updated_at),
})),
},
]
}
async function fetchDepartments() {
const res = await departmentsApi.list()
return departmentsApi.toRows(res)
}
export default function Departments() {
const { toast } = useToast()
const { can } = useAuth()
const qc = useQueryClient()
const canCreate = can('department.create')
const canEdit = can('department.edit')
const canExport = can('department.export')
const canViewRequisitions = can('requisitions.view')
const listQuery = useQuery({
queryKey: qk.departments.list(),
queryFn: fetchDepartments,
})
const rowsAll = listQuery.data ?? []
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
// { row: null | department, editable: boolean }
const [editor, setEditor] = useState(null)
const requisitionsQuery = useQuery({
queryKey: qk.requisitions.openCount(),
queryFn: async () => (await requisitionsApi.countOpen())?.data?.open ?? 0,
enabled: canViewRequisitions,
})
const openRequisitions = requisitionsQuery.data ?? null
const stats = useMemo(
() => ({ ...departmentStats(rowsAll), openRequisitions }),
[rowsAll, openRequisitions],
)
const [exporting, setExporting] = useState(false)
async function exportDepartments() {
setExporting(true)
try {
await exportStyledWorkbook({
filename: `departments-${new Date().toISOString().slice(0, 10)}`,
sheets: buildExportSheets(rowsAll, stats, new Date()),
})
} catch (err) {
toast(friendlyAuthError(err, 'Could not export departments.'), 'error')
} finally {
setExporting(false)
}
}
const rows = useMemo(
() =>
rowsAll.filter((r) => {
if (status === 'active' && !r.is_active) return false
if (status === 'inactive' && r.is_active) return false
if (!q) return true
const hay = [r.name, r.short_code, r.subtitle, r.department_head_name]
.filter(Boolean)
.join(' ')
.toLowerCase()
return hay.includes(q.toLowerCase())
}),
[rowsAll, q, status],
)
const pending = listQuery.isPending
return (
<div className="page">
<PageHeader
title="Departments"
sub="Manage departments, ownership, and hiring context used across Jobs, Candidates, and the Inbox filters."
actions={
<>
<button
className="btn btn-secondary"
disabled={!canExport || pending || !rowsAll.length || exporting}
title={!canExport ? 'Requires department.export' : undefined}
onClick={exportDepartments}
>
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'}
</button>
<button
className="btn btn-primary"
disabled={!canCreate}
title={!canCreate ? 'Requires department.create' : undefined}
onClick={() => setEditor({ row: null, editable: true })}
>
<Icon name="plus" /> Create Department
</button>
</>
}
/>
<div className="grid g-kpi mb-18">
<KpiCard label="Total Departments" value={pending ? '—' : stats.total} icon="layers" tone="i-indigo" foot="Across the organization" />
<KpiCard label="Candidates Applied" value={pending ? '—' : stats.candidates} icon="users" tone="i-teal" foot="To jobs in these departments" />
<KpiCard
label="Open Requisitions"
value={openRequisitions ?? '—'}
icon="briefcase"
tone="i-amber"
foot={!canViewRequisitions ? 'Requires requisitions.view' : requisitionsQuery.isError ? 'Couldnt load requisitions' : 'Unlinked, or job still open'}
/>
<KpiCard
label="Avg. Open Job Posts"
value={pending ? '—' : stats.openShareLabel}
icon="reports"
tone="i-purple"
foot={pending ? undefined : `${stats.openRoles} open of ${stats.jobPosts} job posts`}
/>
</div>
{pending && (
<div className="card">
<div className="card-body">
<SkeletonRows rows={6} />
</div>
</div>
)}
{listQuery.isError && (
<div className="card">
<div className="card-body">
<EmptyState icon="layers" title="Couldnt load departments">
{friendlyAuthError(listQuery.error, 'Request failed')}
{' '}This screen needs the <code>department.view</code> permission.
</EmptyState>
</div>
</div>
)}
{!pending && !listQuery.isError && (
<>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search name, code, subtitle, or head…"
/>
</div>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
{rows.length === 0 ? (
<div className="card">
<div className="card-body">
<EmptyState icon="layers" title={rowsAll.length ? 'No departments match' : 'No departments yet'}>
{rowsAll.length
? 'Try adjusting your search or status filter.'
: 'Create the first department to use it across Jobs, Candidates, and the Inbox.'}
</EmptyState>
</div>
</div>
) : (
<div className="grid g-3">
{rows.map((r) => (
<DepartmentCard
key={r.id}
row={r}
canEdit={canEdit}
onView={() => setEditor({ row: r, editable: false })}
onEdit={() => setEditor({ row: r, editable: true })}
/>
))}
</div>
)}
</>
)}
{editor && (
<DepartmentEditor
row={editor.row}
allowed={editor.editable && (editor.row ? canEdit : canCreate)}
departments={rowsAll}
onClose={() => setEditor(null)}
onEdit={canEdit && editor.row && !editor.editable ? () => setEditor({ ...editor, editable: true }) : null}
onSaved={async () => {
await qc.invalidateQueries({ queryKey: qk.departments.all() })
setEditor(null)
}}
toast={toast}
/>
)}
</div>
)
}
function DepartmentCard({ row, canEdit, onView, onEdit }) {
const tone = toneFor(row.id)
const regions = (row.location || []).length
return (
<div className={`card dept-card tone-${tone}`}>
<div className="card-body">
<div className="dept-card-top">
<div className="dept-card-id">
<div className="dept-icn"><Icon name="layers" /></div>
<div className="min-w-0">
<div className="dept-name">
<span>{row.name}</span>
<span className="code-chip">{row.short_code}</span>
</div>
<div className="cell-sub">{row.subtitle || '—'}</div>
</div>
</div>
{canEdit && (
<button className="act-btn" data-tip="Edit" aria-label={`Edit ${row.name}`} onClick={onEdit}>
<Icon name="edit" />
</button>
)}
</div>
<p className="dept-desc">{row.description || <span className="text-muted">No description</span>}</p>
<div className="dept-stats">
<div className="dept-stat">
<div className="dept-stat-v">{row.open_roles ?? 0}</div>
<div className="dept-stat-l">Open Roles</div>
</div>
<div className="dept-stat">
<div className="dept-stat-v">{row.candidates ?? 0}</div>
<div className="dept-stat-l">Candidates</div>
</div>
<div className="dept-stat">
<div className="dept-stat-v">{regions}</div>
<div className="dept-stat-l">{regions === 1 ? 'Region' : 'Regions'}</div>
</div>
</div>
<div className="divider" />
<div className="dept-card-foot">
<div className="dept-head">
{row.department_head_name ? (
<>
<Avatar name={row.department_head_name} className="dept-avatar" />
<span>{row.department_head_name}</span>
</>
) : (
<span className="text-muted">No department head</span>
)}
</div>
<button className="btn btn-secondary btn-sm" onClick={onView}>View</button>
</div>
</div>
</div>
)
}
function blankForm() {
return {
name: '',
short_code: '',
is_active: true,
description: '',
department_head_id: '',
location: [],
subtitle: '',
parent_department_id: '',
}
}
function fromRow(row) {
return {
name: row.name || '',
short_code: row.short_code || '',
is_active: row.is_active !== false,
description: row.description || '',
department_head_id: row.department_head_id || '',
location: row.location || [],
subtitle: row.subtitle || '',
parent_department_id: row.parent_department_id || '',
}
}
function toPayload(f) {
return {
name: f.name.trim(),
short_code: f.short_code.trim().toUpperCase(),
is_active: f.is_active,
description: f.description.trim() || null,
department_head_id: f.department_head_id || null,
location: f.location,
subtitle: f.subtitle.trim() || null,
parent_department_id: f.parent_department_id || null,
}
}
/** Ids of `rootId` and everything under it — none of them may become its parent. */
function selfAndDescendants(rootId, departments) {
const out = new Set([rootId])
let grew = true
while (grew) {
grew = false
departments.forEach((d) => {
if (d.parent_department_id && out.has(d.parent_department_id) && !out.has(d.id)) {
out.add(d.id)
grew = true
}
})
}
return out
}
function DepartmentEditor({ row, allowed, departments, onClose, onEdit, onSaved, toast }) {
const [fields, setFields] = useState(() => (row ? fromRow(row) : blankForm()))
const [errors, setErrors] = useState({})
const set = (k, v) => setFields((f) => ({ ...f, [k]: v }))
// Loaded when the Department Head dropdown is first opened, not on modal mount.
const [headsRequested, setHeadsRequested] = useState(false)
const headsQuery = useQuery({
queryKey: qk.departments.heads({ roleId: DEPARTMENT_HEAD_ROLE_ID }),
queryFn: async () =>
departmentsApi.toRows(await departmentsApi.listHeads({ roleId: DEPARTMENT_HEAD_ROLE_ID })),
enabled: allowed && headsRequested,
})
const requestHeads = () => {
if (!headsRequested) setHeadsRequested(true)
else if (headsQuery.isError) headsQuery.refetch()
}
const headOptions = useMemo(() => {
const opts = headsQuery.data ?? []
// Keep the saved head selectable even when the picker list is unavailable.
if (row?.department_head_id && !opts.some((o) => o.id === row.department_head_id)) {
return [{ id: row.department_head_id, name: row.department_head_name || 'Current head' }, ...opts]
}
return opts
}, [headsQuery.data, row])
const parentOptions = useMemo(() => {
const blocked = row ? selfAndDescendants(row.id, departments) : new Set()
return departments.filter((d) => !blocked.has(d.id))
}, [departments, row])
const save = useMutation({
mutationFn: () => {
const body = toPayload(fields)
if (row) return departmentsApi.update(row.id, body)
return departmentsApi.create(body)
},
onSuccess: async () => {
toast(row ? 'Department updated' : 'Department created', 'success')
await onSaved()
},
onError: (err) => toast(friendlyAuthError(err, 'Could not save the department.'), 'error'),
})
function submit(e) {
e.preventDefault()
const next = {}
if (!fields.name.trim()) next.name = 'Enter the department name'
const code = fields.short_code.trim()
if (!code) next.short_code = 'Enter a short code'
else if (code.length > 10) next.short_code = 'Use 10 characters or fewer'
setErrors(next)
if (Object.keys(next).length) return
save.mutate()
}
const title = !row ? 'Create Department' : allowed ? 'Edit Department' : row.name
return (
<Modal
title={title}
subtitle="Departments power reporting, requisitions, and the Department filter on Candidates & Inbox."
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" type="button" disabled={save.isPending} onClick={onClose}>
{allowed ? 'Cancel' : 'Close'}
</button>
{!allowed && onEdit && (
<button className="btn btn-primary" type="button" onClick={onEdit}>
<Icon name="edit" /> Edit
</button>
)}
{allowed && (
<button className="btn btn-primary" form="department-form" type="submit" disabled={save.isPending}>
<Icon name="check" /> {save.isPending ? 'Saving…' : 'Save Department'}
</button>
)}
</>
}
>
<form id="department-form" noValidate onSubmit={submit}>
<fieldset disabled={!allowed} style={{ border: 0, margin: 0, padding: 0 }}>
<div className="form-grid">
<div className="form-field col-span-2">
<label htmlFor="dept-name">Department Name <span className="req">*</span></label>
<input
id="dept-name"
className={errors.name ? 'err' : ''}
value={fields.name}
maxLength={120}
placeholder="e.g. Global Emerging Operations"
onChange={(e) => set('name', e.target.value)}
/>
<FieldError>{errors.name}</FieldError>
</div>
<div className="form-field">
<label htmlFor="dept-code">Short Code <span className="req">*</span></label>
<input
id="dept-code"
className={errors.short_code ? 'err' : ''}
value={fields.short_code}
maxLength={10}
placeholder="e.g. GEO"
onChange={(e) => set('short_code', e.target.value.toUpperCase())}
/>
<FieldError>{errors.short_code}</FieldError>
</div>
<div className="form-field">
<label htmlFor="dept-status">Status</label>
<div className="dept-status-toggle">
<label className="switch">
<input
id="dept-status"
type="checkbox"
checked={fields.is_active}
onChange={(e) => set('is_active', e.target.checked)}
/>
<span className="switch-track" />
</label>
<span>{fields.is_active ? 'Active' : 'Inactive'}</span>
</div>
</div>
<div className="form-field col-span-2">
<label htmlFor="dept-desc">Description</label>
<textarea
id="dept-desc"
rows={3}
style={{ minHeight: 72 }}
value={fields.description}
placeholder="What this department does and the roles it hires for."
onChange={(e) => set('description', e.target.value)}
/>
</div>
<div className="form-field">
<label htmlFor="dept-head">Department Head</label>
<select
id="dept-head"
value={fields.department_head_id}
onChange={(e) => set('department_head_id', e.target.value)}
onMouseDown={requestHeads}
onFocus={requestHeads}
>
<option value="">
{headsQuery.isFetching ? 'Loading…' : headsQuery.isError ? 'Couldnt load department heads' : 'Not assigned'}
</option>
{headOptions.map((u) => (
<option key={u.id} value={u.id}>{u.email ? `${u.name}${u.email}` : u.name}</option>
))}
</select>
</div>
<div className="form-field">
<label htmlFor="dept-parent">Parent Department</label>
<select
id="dept-parent"
value={fields.parent_department_id}
onChange={(e) => set('parent_department_id', e.target.value)}
>
<option value="">None (top-level)</option>
{parentOptions.map((d) => (
<option key={d.id} value={d.id}>{d.name} ({d.short_code})</option>
))}
</select>
</div>
<div className="form-field col-span-2">
<label htmlFor="dept-subtitle">Subtitle</label>
<input
id="dept-subtitle"
value={fields.subtitle}
maxLength={160}
placeholder="e.g. Store Operations"
onChange={(e) => set('subtitle', e.target.value)}
/>
</div>
<div className="form-field col-span-2">
<label htmlFor="dept-location">Region / Location</label>
<LocationMultiSelect
id="dept-location"
value={fields.location}
disabled={!allowed}
onChange={(next) => set('location', next)}
/>
</div>
</div>
</fieldset>
</form>
</Modal>
)
}
// Rendering all ~800 cities at once makes typing lag; the search narrows it.
const LOCATION_RENDER_LIMIT = 100
/**
* Chip + search picker over GET /department/locations/fetch ("{City} - {Country}").
* Same markup as Jobs' RecruiterMultiSelect. Value is the list of selected labels;
* the options load the first time the field is focused.
*/
function LocationMultiSelect({ id, value = [], onChange, disabled = false }) {
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
const root = useRef(null)
const listRef = useRef(null)
const locationsQuery = useQuery({
queryKey: qk.departments.locations(),
queryFn: async () => departmentsApi.toRows(await departmentsApi.listLocations()),
enabled: open,
staleTime: Infinity,
})
useEffect(() => {
function onDoc(e) {
if (root.current && !root.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
// Scroll the modal so the opened list is visible, not below the fold.
useEffect(() => {
if (open) listRef.current?.scrollIntoView({ block: 'nearest' })
}, [open, locationsQuery.isSuccess])
const selected = value || []
const term = q.trim().toLowerCase()
const filtered = (locationsQuery.data ?? []).filter(
(label) => !selected.includes(label) && (!term || label.toLowerCase().includes(term)),
)
function add(label) {
onChange([...selected, label])
setQ('')
}
function remove(label) {
onChange(selected.filter((x) => x !== label))
}
return (
<div className="job-recruiter-multi" ref={root}>
{selected.length > 0 && (
<div className="job-recruiter-chips">
{selected.map((label) => (
<span className="job-recruiter-chip" key={label}>
{label}
<button
type="button"
className="job-recruiter-chip-x"
aria-label={`Remove ${label}`}
disabled={disabled}
onClick={() => remove(label)}
>
×
</button>
</span>
))}
</div>
)}
<input
id={id}
value={open ? q : ''}
disabled={disabled}
placeholder={selected.length ? 'Add another location…' : 'Search city or country…'}
autoComplete="off"
onFocus={() => { setOpen(true); setQ('') }}
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
/>
{open && !disabled && (
// In the normal flow, not position:absolute .modal-body scrolls, so an
// absolute menu is clipped at its edge. This list grows the modal instead.
<div className="dept-location-list" ref={listRef} role="listbox">
{locationsQuery.isFetching && <div className="dept-location-note">Loading</div>}
{locationsQuery.isError && (
<button type="button" className="dept-location-option text-muted" onClick={() => locationsQuery.refetch()}>
Couldnt load locations retry
</button>
)}
{locationsQuery.isSuccess && filtered.length === 0 && (
<div className="dept-location-note">No matches</div>
)}
{filtered.slice(0, LOCATION_RENDER_LIMIT).map((label) => (
<button type="button" role="option" key={label} className="dept-location-option" onClick={() => add(label)}>
{label}
</button>
))}
{filtered.length > LOCATION_RENDER_LIMIT && (
<div className="dept-location-note">Type to narrow {filtered.length - LOCATION_RENDER_LIMIT} more</div>
)}
</div>
)}
</div>
)
}

View File

@ -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={{

View File

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

View File

@ -14,8 +14,8 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
import AiFieldAssist from '../ui/AiFieldAssist'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import SearchSelect from '../ui/SearchSelect'
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,10 +25,10 @@ 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'
import * as departmentsApi from '../api/departments'
import * as offersApi from '../api/offers'
import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format'
import { empTypes } from '../data/seed'
@ -46,7 +46,7 @@ function deptValue(j) {
return String(j.requisitionDepartment || j.department || '').trim()
}
function deptLabel(j) {
export function deptLabel(j) {
return deptValue(j) || '—'
}
@ -107,31 +107,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')) {
@ -143,15 +139,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 }) => {
@ -210,17 +199,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],
@ -292,7 +270,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>
)}
@ -381,36 +359,15 @@ 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}
departmentOptions={departmentOptions}
busy={updateJob.isPending}
onClose={() => setEditing(null)}
onSubmit={(body) => updateJob.mutate({ id: editing.id, body })}
@ -419,7 +376,6 @@ export default function Jobs() {
{creating && (
<JobForm
departmentOptions={departmentOptions}
busy={createJob.isPending}
onClose={() => setCreating(false)}
onSubmit={(payload, imageFile) => createJob.mutate({ payload, imageFile })}
@ -429,7 +385,7 @@ export default function Jobs() {
)
}
const SECTION_LABEL = {
export const SECTION_LABEL = {
fontSize: 12, color: 'var(--text-3)', fontWeight: 600,
textTransform: 'uppercase', marginBottom: 6,
}
@ -442,91 +398,6 @@ function fmtWhen(value) {
return fmtDateTime(value) || null
}
/**
* Searchable picker: type to filter, click a row to store the id.
* Not free-text the value is always an option id (or '' when allowEmpty).
*/
function SearchSelect({
options = [],
value,
onChange,
placeholder = 'Search…',
disabled = false,
loading = false,
allowEmpty = false,
emptyLabel = 'Unassigned',
error = false,
onQueryChange,
}) {
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
const root = useRef(null)
const selected = options.find((o) => String(o.id) === String(value || ''))
useEffect(() => {
function onDoc(e) {
if (root.current && !root.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
useEffect(() => {
if (!onQueryChange || !open) return
onQueryChange(q)
}, [q, open, onQueryChange])
const term = q.trim().toLowerCase()
const filtered = onQueryChange
? options
: options.filter((o) => {
if (!term) return true
const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase()
return hay.includes(term)
})
return (
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
<input
className={error ? 'err' : ''}
value={open ? q : (selected?.name || '')}
disabled={disabled || loading}
placeholder={loading ? 'Loading…' : placeholder}
autoComplete="off"
onFocus={() => { setOpen(true); setQ('') }}
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
/>
{open && !disabled && !loading && (
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
{allowEmpty && (
<button
type="button"
className="dropdown-link"
onClick={() => { onChange(''); setQ(''); setOpen(false) }}
>
{emptyLabel}
</button>
)}
{filtered.length === 0 && (
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>No matches</div>
)}
{filtered.map((o) => (
<button
type="button"
key={o.id}
className="dropdown-link"
onClick={() => { onChange(String(o.id)); setQ(''); setOpen(false) }}
>
{o.name}
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
</button>
))}
</div>
)}
</div>
)
}
function sameIdList(a, b) {
const x = [...(a || [])].map(String)
const y = [...(b || [])].map(String)
@ -656,6 +527,49 @@ function useRecruiterDirectory() {
})
}
/**
* Department search dropdown over GET /department/names?search=. Options are
* `{id, name}`; the picked row is kept in the list so a selection made from an
* earlier search (or the job's saved department) still renders its name.
*/
function useDepartmentPicker(initialPicked = null) {
const [deptQ, setDeptQ] = useState('')
const [debouncedDeptQ, setDebouncedDeptQ] = useState('')
const [pickedDept, setPickedDept] = useState(initialPicked)
useEffect(() => {
const t = setTimeout(() => setDebouncedDeptQ(deptQ.trim()), 250)
return () => clearTimeout(t)
}, [deptQ])
const departmentsQuery = useQuery({
queryKey: qk.departments.names(debouncedDeptQ),
queryFn: async () => departmentsApi.toRows(await departmentsApi.listNames({ search: debouncedDeptQ })),
placeholderData: keepPreviousData,
retry: false,
})
const departmentOptions = useMemo(() => {
const rows = departmentsQuery.data ?? []
if (pickedDept && !rows.some((o) => String(o.id) === String(pickedDept.id))) {
return [pickedDept, ...rows]
}
return rows
}, [departmentsQuery.data, pickedDept])
/** Pick by name — the requisition picker only knows its department as text. */
async function pickByName(name) {
const term = String(name || '').trim().toLowerCase()
if (!term) return null
const rows = departmentsApi.toRows(await departmentsApi.listNames({ search: name.trim() }))
const match = rows.find((d) => String(d.name).trim().toLowerCase() === term)
if (match) setPickedDept(match)
return match || null
}
return { departmentOptions, departmentsQuery, setDeptQ, pickedDept, setPickedDept, pickByName }
}
function useRequisitionPicker(initialPicked = null, jobPostId = null) {
const [reqQ, setReqQ] = useState('')
const [debouncedReqQ, setDebouncedReqQ] = useState('')
@ -693,17 +607,43 @@ function useRequisitionPicker(initialPicked = null, jobPostId = null) {
return { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq }
}
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
/** The Department field on both job forms: search dropdown, stores department_id. */
function DepartmentSearchSelect({ dept, value, onChange, disabled }) {
return (
<>
<SearchSelect
options={dept.departmentOptions}
value={value}
onChange={(id) => {
onChange(id)
dept.setPickedDept(dept.departmentOptions.find((o) => String(o.id) === String(id)) || null)
}}
onQueryChange={dept.setDeptQ}
placeholder="Search departments…"
disabled={disabled}
loading={dept.departmentsQuery.isPending && !dept.departmentsQuery.data}
allowEmpty
emptyLabel="No department"
/>
{dept.departmentsQuery.isError && (
<p className="text-muted text-sm">Could not load departments.</p>
)}
</>
)
}
function JobForm({ busy, onClose, onSubmit }) {
const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker()
const dept = useDepartmentPicker()
const form = useFormState({
hiring_manager_id: '',
current_recruiter_ids: [],
requisition_id: '',
title: '',
department: '',
department_id: '',
location: '',
employment_type: empTypes[0] || 'Full-time',
vacancies: '1',
@ -778,7 +718,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
// The cover image is uploaded separately right after the row exists.
onSubmit({
title: v.title.trim(),
department: v.department.trim() || null,
department_id: v.department_id || null,
location: v.location.trim() || null,
employment_type: v.employment_type || null,
vacancies,
@ -802,7 +742,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
// field itself and empty values before building the prompt.
const assistContext = () => ({
title: form.values.title,
department: form.values.department,
department: dept.pickedDept?.name || '',
location: form.values.location,
employment_type: form.values.employment_type,
experience_min: form.values.experience_min,
@ -856,7 +796,9 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
if (opt) {
setPickedReq(opt)
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
if (!form.values.department_id && opt.department) {
dept.pickByName(opt.department).then((d) => { if (d) form.setField('department_id', d.id) })
}
}
}}
onQueryChange={setReqQ}
@ -881,7 +823,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
</div>
<div className="form-field">
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
<label>Hiring manager</label>
<SearchSelect
options={managersQuery.data ?? []}
value={form.values.hiring_manager_id}
@ -914,16 +856,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
<div className="form-field">
<div className="field-label-row">
<label>Department</label>
{assist('department')}
</div>
<input
{...field('department')}
list="job-department-options"
placeholder="e.g. Engineering"
/>
<datalist id="job-department-options">
{departmentOptions.map((d) => <option key={d} value={d} />)}
</datalist>
<DepartmentSearchSelect dept={dept} value={form.values.department_id} onChange={(id) => form.setField('department_id', id)} disabled={busy} />
</div>
<div className="form-field">
<div className="field-label-row">
@ -1059,7 +993,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
)
}
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
@ -1073,10 +1007,11 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
: null,
j.id,
)
const dept = useDepartmentPicker(j.departmentId ? { id: j.departmentId, name: j.department || 'Current department' } : null)
const form = useFormState({
requisition_id: j.requisitionId || '',
title: j.title || '',
department: j.department || '',
department_id: j.departmentId || '',
location: j.location || '',
employment_type: j.type || '',
vacancies: j.vacancies != null ? String(j.vacancies) : '1',
@ -1089,7 +1024,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
const assistContext = () => ({
title: form.values.title,
department: form.values.department,
department: dept.pickedDept?.name || '',
location: form.values.location,
employment_type: form.values.employment_type,
experience_min: form.values.experience_min,
@ -1118,7 +1053,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
if (Object.keys(errors).length) return
onSubmit({
title,
department: form.values.department.trim() || null,
department_id: form.values.department_id || null,
location: form.values.location.trim() || null,
employment_type: form.values.employment_type || null,
vacancies: Number(form.values.vacancies) || 1,
@ -1163,7 +1098,9 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
if (opt) {
setPickedReq(opt)
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
if (!form.values.department_id && opt.department) {
dept.pickByName(opt.department).then((d) => { if (d) form.setField('department_id', d.id) })
}
}
}}
onQueryChange={setReqQ}
@ -1186,7 +1123,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
<FieldError>{form.errors.title}</FieldError>
</div>
<div className="form-field">
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
<label>Hiring manager</label>
<SearchSelect
options={managersQuery.data ?? []}
value={form.values.hiring_manager_id}
@ -1212,10 +1149,8 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
<div className="form-field">
<div className="field-label-row">
<label>Department</label>
{assist('department')}
</div>
<input list="edit-job-depts" value={form.values.department} onChange={(e) => form.setField('department', e.target.value)} disabled={busy} />
<datalist id="edit-job-depts">{departmentOptions.map((d) => <option key={d} value={d} />)}</datalist>
<DepartmentSearchSelect dept={dept} value={form.values.department_id} onChange={(id) => form.setField('department_id', id)} disabled={busy} />
</div>
<div className="form-field">
<div className="field-label-row">
@ -1260,7 +1195,7 @@ function EditJobForm({ job: j, departmentOptions, 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()
@ -1284,7 +1219,7 @@ function JobOwnership({ job, canEdit }) {
<div style={SECTION_LABEL}>Ownership</div>
<div className="form-grid" style={{ marginBottom: 12 }}>
<div className="form-field">
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
<label>Hiring manager</label>
{canEdit ? (
<SearchSelect
options={managersQuery.data ?? []}
@ -1333,7 +1268,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 ?? []
@ -1459,7 +1394,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
@ -1476,136 +1411,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>
)
}

View File

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

View File

@ -13,6 +13,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import DepartmentSelect from '../ui/DepartmentSelect'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast'
@ -269,7 +270,8 @@ export default function Requisitions() {
function blankForm() {
return {
department: '',
department_id: '',
department_name: '',
title: '',
date: toDateInput(new Date().toISOString()),
date_needed: '',
@ -308,7 +310,8 @@ function fromRow(row) {
const rep = row.replacement_for || {}
const ref = row.refferal_by || {}
return {
department: pos.department || '',
department_id: pos.department_id ? String(pos.department_id) : '',
department_name: pos.department || '',
title: pos.title || '',
date: toDateInput(pos.date),
date_needed: toDateInput(pos.date_needed),
@ -345,7 +348,8 @@ function fromRow(row) {
function toPayload(f) {
const body = {
position: {
department: emptyToNull(f.department),
department_id: emptyToNull(f.department_id),
department: emptyToNull(f.department_name),
title: emptyToNull(f.title),
date: emptyToNull(f.date),
date_needed: emptyToNull(f.date_needed),
@ -453,7 +457,14 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
<div className="form-grid">
<div className="form-field">
<label>From (Dept.)</label>
<input value={fields.department} onChange={(e) => set('department', e.target.value)} />
<DepartmentSelect
value={fields.department_id}
onChange={(id, name) => {
set('department_id', id)
set('department_name', name)
}}
fallbackName={fields.department_name}
/>
</div>
<div className="form-field">
<label>Job title <span className="req">*</span></label>
@ -507,7 +518,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
/>
</div>
<div className="form-field">
<label>
<label className="hf-check">
<input
type="checkbox"
checked={fields.jd_available}
@ -642,7 +653,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
</div>
<div className="hf-sign">
<span className="hf-sign-role">Approved by · Director HR</span>
<label className="hf-note" style={{ margin: 0 }}>
<label className="hf-note hf-check" style={{ margin: 0 }}>
<input
type="checkbox"
checked={fields.approved_by_hr}
@ -658,7 +669,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
</div>
<div className="hf-sign">
<span className="hf-sign-role">Approved by · VP</span>
<label className="hf-note" style={{ margin: 0 }}>
<label className="hf-note hf-check" style={{ margin: 0 }}>
<input
type="checkbox"
checked={fields.approved_by_vp}
@ -674,7 +685,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
</div>
<div className="hf-sign">
<span className="hf-sign-role">Approved by · SVP</span>
<label className="hf-note" style={{ margin: 0 }}>
<label className="hf-note hf-check" style={{ margin: 0 }}>
<input
type="checkbox"
checked={fields.approved_by_svp}

View File

@ -769,7 +769,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.form-section-title { margin: var(--space-5) 0 var(--space-1); grid-column: 1/-1; }
/* AI field assist (ui/AiFieldAssist.jsx) */
.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 22px; }
.ai-assist { position: relative; display: inline-flex; }
.ai-assist-btn { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 6px; color: var(--primary); background: transparent; transition: .15s; }
.ai-assist-btn svg { width: 14px; height: 14px; }
@ -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. */
@ -1937,6 +1961,10 @@ canvas { width: 100%; max-width: 100%; display: block; }
.hf-block-title { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
.hf-block-title label { display: flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 600; color: var(--text-2); }
.hf-note { font-size: 12.5px; color: var(--text-3); margin: 2px 0 10px; }
/* Checkbox + text on one line, box flush left. Overrides `.form-field input`
(width:100% + padding), which otherwise stretches the checkbox and centres it. */
.hf-check { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; cursor: pointer; }
.hf-check input[type="checkbox"] { width: auto; padding: 0; margin: 0; flex: none; }
/* Rating table: the paper grid — scale header, radio-dot cells, average foot */
.hf-rate { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
@ -2278,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); }
@ -2289,3 +2318,35 @@ canvas { width: 100%; max-width: 100%; display: block; }
@media (max-width: 400px) {
.hf-summary { grid-template-columns: 1fr; }
}
/* ================= DEPARTMENTS ================= */
.dept-card { --tone: var(--primary); --tone-soft: var(--primary-soft); display: flex; flex-direction: column; }
.dept-card.tone-success { --tone: var(--success); --tone-soft: var(--success-soft); }
.dept-card.tone-warning { --tone: var(--warning); --tone-soft: var(--warning-soft); }
.dept-card.tone-purple { --tone: var(--purple); --tone-soft: var(--purple-soft); }
.dept-card.tone-info { --tone: var(--info); --tone-soft: var(--info-soft); }
.dept-card > .card-body { display: flex; flex-direction: column; flex: 1; }
.dept-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
.dept-card-id { display: flex; align-items: center; gap: 12px; min-width: 0; }
.dept-icn { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; flex: none; background: var(--tone-soft); color: var(--tone); }
.dept-icn svg { width: 20px; height: 20px; }
.dept-name { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; font-size: var(--fs-md); font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
.code-chip { font-size: 11px; font-weight: 700; letter-spacing: .4px; padding: 2px 8px; border-radius: 20px; background: var(--tone-soft); color: var(--tone); }
.dept-desc { font-size: var(--fs-sm); color: var(--text-2); line-height: 1.5; margin: 0 0 16px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.dept-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: auto; }
.dept-stat { text-align: center; min-width: 0; }
.dept-stat-v { font-size: 18px; font-weight: 600; line-height: 1.3; }
.dept-stat-v.is-on, .dept-stat-v.is-off { font-size: var(--fs-sm); line-height: 23px; }
.dept-stat-v.is-on { color: var(--success); }
.dept-stat-v.is-off { color: var(--text-3); }
.dept-stat-l { font-size: 11px; color: var(--text-3); margin-top: 2px; }
.dept-card .divider { margin: 14px 0; }
.dept-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.dept-head { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: var(--fs-sm); color: var(--text-2); }
.dept-head > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dept-avatar { width: 26px; height: 26px; font-size: 10px; }
.dept-status-toggle { display: flex; align-items: center; gap: 10px; padding: 7px 12px; border: 1px solid var(--border-strong); border-radius: 9px; font-size: var(--fs-sm); min-height: 40px; }
.dept-location-list { max-height: 320px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-elev); box-shadow: var(--shadow-sm); padding: 4px; }
.dept-location-option { display: block; width: 100%; text-align: left; padding: 8px 10px; border-radius: 7px; font-size: var(--fs-sm); color: var(--text); }
.dept-location-option:hover, .dept-location-option:focus-visible { background: var(--bg-sunken); outline: none; }
.dept-location-note { padding: 8px 10px; font-size: var(--fs-sm); color: var(--text-3); }

View File

@ -0,0 +1,68 @@
/* ============================================================
DepartmentSelect the "From (Dept.)" / "Department" picker.
Wraps SearchSelect over GET /department/names (`{data:[{id,name}]}`) and
hands back both the department's id and its name. The server filters on `search`,
so typing is debounced and SearchSelect is told not to filter again locally.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import SearchSelect from './SearchSelect'
import { qk } from '../lib/queryKeys'
import * as departmentsApi from '../api/departments'
/**
* @param value selected department id, '' when none
* @param onChange (id, name) => void receives ('', '') when cleared
* @param fallbackName name of the saved department, used only to label it when
* the list does not contain it (see below)
*/
export default function DepartmentSelect({ value, onChange, fallbackName = '', disabled = false, error = false }) {
const [q, setQ] = useState('')
const [term, setTerm] = useState('')
useEffect(() => {
const t = setTimeout(() => setTerm(q.trim()), 250)
return () => clearTimeout(t)
}, [q])
const query = useQuery({
queryKey: qk.departments.names(term),
queryFn: async () => departmentsApi.toRows(await departmentsApi.listNames({ search: term })),
placeholderData: keepPreviousData,
retry: false,
})
// Two ways the saved department is missing from `query.data`: it was since
// deactivated (the endpoint returns active only), or the user has typed a
// search that excludes it. Keep it in the list either way so opening the form
// or typing and then clearing never silently drops the selection.
const options = useMemo(() => {
const rows = query.data ?? []
const picked = String(value || '')
if (picked && !rows.some((d) => String(d.id) === picked)) {
return [{ id: picked, name: fallbackName || 'Current department' }, ...rows]
}
return rows
}, [query.data, value, fallbackName])
return (
<>
<SearchSelect
options={options}
value={value}
onChange={(id) => onChange(id, options.find((d) => String(d.id) === String(id))?.name ?? '')}
onQueryChange={setQ}
placeholder="Search departments…"
disabled={disabled}
loading={query.isPending && !query.data}
error={error}
allowEmpty
emptyLabel="No department"
/>
{query.isError && <p className="text-muted text-sm">Could not load departments.</p>}
</>
)
}

View File

@ -0,0 +1,94 @@
/* ============================================================
SearchSelect type-to-filter picker whose value is always an option id.
Moved out of screens/Jobs.jsx so Requisitions can use the same control.
Options are `{ id, name, email?, role_name? }`. Pass `onQueryChange` when the
caller filters server-side; without it the list is filtered in place.
============================================================ */
import { useEffect, useRef, useState } from 'react'
/**
* Searchable picker: type to filter, click a row to store the id.
* Not free-text the value is always an option id (or '' when allowEmpty).
*/
export default function SearchSelect({
options = [],
value,
onChange,
placeholder = 'Search…',
disabled = false,
loading = false,
allowEmpty = false,
emptyLabel = 'Unassigned',
error = false,
onQueryChange,
}) {
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
const root = useRef(null)
const selected = options.find((o) => String(o.id) === String(value || ''))
useEffect(() => {
function onDoc(e) {
if (root.current && !root.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
useEffect(() => {
if (!onQueryChange || !open) return
onQueryChange(q)
}, [q, open, onQueryChange])
const term = q.trim().toLowerCase()
const filtered = onQueryChange
? options
: options.filter((o) => {
if (!term) return true
const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase()
return hay.includes(term)
})
return (
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
<input
className={error ? 'err' : ''}
value={open ? q : (selected?.name || '')}
disabled={disabled || loading}
placeholder={loading ? 'Loading…' : placeholder}
autoComplete="off"
onFocus={() => { setOpen(true); setQ('') }}
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
/>
{open && !disabled && !loading && (
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
{allowEmpty && (
<button
type="button"
className="dropdown-link"
onClick={() => { onChange(''); setQ(''); setOpen(false) }}
>
{emptyLabel}
</button>
)}
{filtered.length === 0 && (
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>No matches</div>
)}
{filtered.map((o) => (
<button
type="button"
key={o.id}
className="dropdown-link"
onClick={() => { onChange(String(o.id)); setQ(''); setOpen(false) }}
>
{o.name}
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
</button>
))}
</div>
)}
</div>
)
}

View File

@ -23,7 +23,7 @@ export default defineConfig({
// VITE_API_TARGET repoints the proxy when the API runs elsewhere
// (e.g. 8001 locally because another service holds 8000).
proxy: {
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox|s3|forms|requisitions)(/|$)': {
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox|s3|forms|requisitions|department)(/|$)': {
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:8000',
changeOrigin: true,
// Several API prefixes double as SPA routes (/jobs, /inbox, …).