Compare commits
29 Commits
main
...
Interview_
| Author | SHA1 | Date |
|---|---|---|
|
|
6bbf33346f | |
|
|
3838634e82 | |
|
|
b841677bc3 | |
|
|
7d185c3ff3 | |
|
|
cf9cf7ca8e | |
|
|
09635eb679 | |
|
|
c233d23c29 | |
|
|
eaec58bd0e | |
|
|
a2932fd1bf | |
|
|
43852e22e0 | |
|
|
1fa25f2854 | |
|
|
de472a8155 | |
|
|
4fed17850a | |
|
|
101b1a18ce | |
|
|
e3914c3c58 | |
|
|
bebe87e1b7 | |
|
|
51bd8b56f2 | |
|
|
91bc1089b2 | |
|
|
1c4a6696cc | |
|
|
377be6d786 | |
|
|
433b2086b7 | |
|
|
3c0421c2ec | |
|
|
cf605ee3d6 | |
|
|
f86c908076 | |
|
|
eee2071ac2 | |
|
|
addbc77d2b | |
|
|
ff1f91973d | |
|
|
df684c421b | |
|
|
3e11d2812e |
|
|
@ -3,7 +3,7 @@ name: Deploy to S3
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- dev_main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
apt-get update -y
|
apt-get update -y
|
||||||
apt-get install -y zip
|
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 ".git/*" \
|
||||||
-x ".gitea/*" \
|
-x ".gitea/*" \
|
||||||
-x ".gitignore/*" \
|
-x ".gitignore/*" \
|
||||||
|
|
@ -46,7 +46,10 @@ jobs:
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
AWS_DEFAULT_REGION: us-east-1
|
||||||
run: |
|
run: |
|
||||||
echo "Uploading repo contents to S3..."
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,4 +93,6 @@ frontend/dist/index.html
|
||||||
frontend/dist/**
|
frontend/dist/**
|
||||||
nginx.conf
|
nginx.conf
|
||||||
smoke.test.mjs
|
smoke.test.mjs
|
||||||
Annex**
|
**.docx
|
||||||
|
**.docs
|
||||||
|
Annex**
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -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 |
|
||||||
|
|
@ -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
|
||||||
|
|
@ -4,127 +4,311 @@ 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.
|
||||||
|
|
||||||
|
### 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 = 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
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
```
|
||||||
|
|
||||||
|
- `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
|
||||||
|
class RequisitionForm:
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
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)
|
||||||
|
|
||||||
|
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]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### 4. `plugins.py` — shared helpers
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### 5. `models.py` — SQLModel table + classmethod accessors
|
||||||
|
|
||||||
|
```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)
|
||||||
|
|
||||||
|
@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())
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
@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)
|
## Package layout (every domain)
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/<domain>/
|
backend/<domain>/
|
||||||
app.py # routes only — HTTP in/out
|
app.py # routes + inline request models
|
||||||
views.py # service class — business logic
|
views.py # service class
|
||||||
models.py # SQLModel table + classmethod DB accessors
|
models.py # SQLModel table + classmethod accessors
|
||||||
serializers.py # hand-rolled dict builders (no Pydantic response models)
|
serializers.py # serialize_* dict builders
|
||||||
plugins.py # pure helpers (hash, JWT, clean payload) — NO FastAPI imports
|
enums.py # str Enums + nested request BaseModel blocks
|
||||||
permissions.py # OAuth2 scheme + Depends aliases (auth domains only)
|
plugins.py # helpers + constants
|
||||||
|
permissions.py # auth domains only
|
||||||
```
|
```
|
||||||
|
|
||||||
- Bare `router = APIRouter()`; mount in `main.py` with `app.include_router(...)`.
|
- No `__init__.py`. Run from `backend/`; imports are top-level (`candidate_forms.views`, `db_setup`, `users.permissions`).
|
||||||
- No package `__init__.py`. Run from `backend/` so imports are top-level (`users.app`, `db_setup`).
|
- Config: module-level `load_dotenv()` + `os.getenv(...)`. Do not extend `db_setup.Settings` for app secrets.
|
||||||
- Non-DB config: module-level `load_dotenv()` + `os.getenv(...)`. Do **not** extend `db_setup.Settings` for app secrets.
|
|
||||||
|
|
||||||
## Layer duties (non-negotiable)
|
## Layer duties
|
||||||
|
|
||||||
| Layer | Owns | Must NOT do |
|
| Layer | Owns | Must NOT |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `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 |
|
| `app.py` | Routes, inline request models, `Depends`, `JSONResponse` envelope | Business rules, SQL, helper 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 |
|
| `views.py` | Payload prep, business checks, `HTTPException`, call model, return serializer | SQL, commits, helper definitions |
|
||||||
| `models.py` | Fields, queries, inserts/updates/soft-delete, `selectinload` when needed | HTTPException, FastAPI, serializers |
|
| `models.py` | Columns, relationships, queries, insert/update/soft-delete + commit | `HTTPException`, serializers |
|
||||||
| `serializers.py` | `serialize_*` → plain `dict` | DB, Depends |
|
| `serializers.py` | `serialize_*` → `dict` | DB, `Depends` |
|
||||||
| `plugins.py` | Pure helpers; raise library errors (`jwt.*`) | Import FastAPI / raise HTTPException |
|
| `enums.py` | Enums, nested request blocks | Logic |
|
||||||
| `permissions.py` | `OAuth2PasswordBearer`, `get_current_user`, `CurrentUser` alias, `require_permission` | Route handlers |
|
| `plugins.py` | Every helper and constant | Routes, DB writes |
|
||||||
|
|
||||||
## 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:
|
|
||||||
|
|
||||||
```python
|
|
||||||
try:
|
|
||||||
service=User(session=session)
|
|
||||||
data=await service.some_method(...)
|
|
||||||
return JSONResponse(content={"data":data,"status_code":200})
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
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:
|
|
||||||
|
|
||||||
```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:
|
|
||||||
def __init__(self,session:AsyncSession):
|
|
||||||
self.session=session
|
|
||||||
|
|
||||||
async def create_user(self,payload):
|
|
||||||
...
|
|
||||||
return serialize_user(user)
|
|
||||||
```
|
|
||||||
|
|
||||||
- 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.)
|
|
||||||
|
|
||||||
## Exact model pattern (`models.py`)
|
|
||||||
|
|
||||||
- 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).
|
|
||||||
|
|
||||||
## 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`.
|
|
||||||
|
|
||||||
## Auth (when touching users auth)
|
|
||||||
|
|
||||||
- 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()`.
|
|
||||||
|
|
||||||
## Dependencies / env
|
|
||||||
|
|
||||||
- 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`.
|
|
||||||
|
|
||||||
## Hard bans
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Workflow when adding an endpoint
|
## Workflow when adding an endpoint
|
||||||
|
|
||||||
1. Model accessor (if DB).
|
1. Migration SQL if the schema changes.
|
||||||
2. Service method in `views.py`.
|
2. Columns + one classmethod accessor in `models.py`.
|
||||||
3. `serialize_*` if new shape.
|
3. Enum / nested block in `enums.py` if the body has one.
|
||||||
4. Route in `app.py` with the standard try/except + `JSONResponse`.
|
4. Helper in `plugins.py` only if a view needs one that does not already exist.
|
||||||
5. Add `current_user: CurrentUser` or `Depends(require_permission(...))` if the route is protected.
|
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 file’s 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.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ Django-shaped aliases (same behaviour, different names):
|
||||||
python alembic_setup.py upgrade # apply versions/*.py (like migrate)
|
python alembic_setup.py upgrade # apply versions/*.py (like migrate)
|
||||||
python alembic_setup.py stamp -r f3a7e5b34c86 # bookmark only; no DDL
|
python alembic_setup.py stamp -r f3a7e5b34c86 # bookmark only; no DDL
|
||||||
|
|
||||||
|
One command for every pending change — schema from all models, manual SQL, RBAC:
|
||||||
|
|
||||||
|
python alembic_setup.py sync --dry-run > review.sql # print the SQL, run nothing
|
||||||
|
python alembic_setup.py sync # apply it (drops skipped)
|
||||||
|
python alembic_setup.py sync --allow-drops # also drop tables/columns/indexes
|
||||||
|
|
||||||
Named `alembic_setup` rather than `alembic` because `backend/` is on `sys.path`,
|
Named `alembic_setup` rather than `alembic` because `backend/` is on `sys.path`,
|
||||||
so a module called `alembic.py` would shadow the installed package.
|
so a module called `alembic.py` would shadow the installed package.
|
||||||
"""
|
"""
|
||||||
|
|
@ -21,6 +27,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
@ -32,6 +39,7 @@ from alembic import command
|
||||||
from alembic.autogenerate import compare_metadata, produce_migrations, render_python_code
|
from alembic.autogenerate import compare_metadata, produce_migrations, render_python_code
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from alembic.operations import Operations
|
from alembic.operations import Operations
|
||||||
|
from alembic.operations import ops as alembic_ops
|
||||||
from alembic.runtime.migration import MigrationContext
|
from alembic.runtime.migration import MigrationContext
|
||||||
from alembic.script import ScriptDirectory
|
from alembic.script import ScriptDirectory
|
||||||
from alembic.script.revision import ResolutionError
|
from alembic.script.revision import ResolutionError
|
||||||
|
|
@ -40,7 +48,15 @@ from alembic.util.exc import CommandError
|
||||||
from sqlalchemy import MetaData, text
|
from sqlalchemy import MetaData, text
|
||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
from db_setup import BASE_DIR, Base, close_db, database_url, get_engine, get_settings
|
from db_setup import (
|
||||||
|
BASE_DIR,
|
||||||
|
Base,
|
||||||
|
close_db,
|
||||||
|
create_schemas,
|
||||||
|
database_url,
|
||||||
|
get_engine,
|
||||||
|
get_settings,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("db.alembic")
|
logger = logging.getLogger("db.alembic")
|
||||||
|
|
||||||
|
|
@ -182,6 +198,7 @@ def config(connection: Connection | None = None) -> Config:
|
||||||
|
|
||||||
VERSION_TABLE = "alembic_version"
|
VERSION_TABLE = "alembic_version"
|
||||||
MANUAL_TABLE = "manual_migrations"
|
MANUAL_TABLE = "manual_migrations"
|
||||||
|
RBAC_LEDGER_TABLE = "rbac_sync_ledger" # mirrors role.plugins.RBAC_LEDGER_TABLE
|
||||||
|
|
||||||
|
|
||||||
def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to: Any) -> bool:
|
def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to: Any) -> bool:
|
||||||
|
|
@ -194,7 +211,7 @@ def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to
|
||||||
return False
|
return False
|
||||||
if type_ != "table":
|
if type_ != "table":
|
||||||
return True
|
return True
|
||||||
if name in (VERSION_TABLE, MANUAL_TABLE): # migration bookkeeping; never ours to alter
|
if name in (VERSION_TABLE, MANUAL_TABLE, RBAC_LEDGER_TABLE): # bookkeeping; never ours to alter
|
||||||
return False
|
return False
|
||||||
return not s.db_schemas or (obj.schema or s.db_default_schema) in s.db_schemas
|
return not s.db_schemas or (obj.schema or s.db_default_schema) in s.db_schemas
|
||||||
|
|
||||||
|
|
@ -334,24 +351,56 @@ async def downgrade(revision: str = "-1") -> None:
|
||||||
logger.info("downgraded to %s", revision)
|
logger.info("downgraded to %s", revision)
|
||||||
|
|
||||||
|
|
||||||
def _apply_upgrade_ops(connection: Connection) -> int:
|
_DESTRUCTIVE_OPS = (
|
||||||
"""Apply ORM→DB diffs in-process without writing a revision file."""
|
alembic_ops.DropTableOp,
|
||||||
|
alembic_ops.DropColumnOp,
|
||||||
|
alembic_ops.DropIndexOp,
|
||||||
|
alembic_ops.DropConstraintOp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_ops(connection: Connection) -> list[Any]:
|
||||||
|
"""The ORM→DB diff as a flat list of Alembic operations, in revision-file order."""
|
||||||
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
|
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
|
||||||
ctx = MigrationContext.configure(connection, opts=opts)
|
ctx = MigrationContext.configure(connection, opts=opts)
|
||||||
script = produce_migrations(ctx, target_metadata())
|
script = produce_migrations(ctx, target_metadata())
|
||||||
if script.upgrade_ops.is_empty():
|
flat: list[Any] = []
|
||||||
return 0
|
|
||||||
operations = Operations(ctx)
|
def walk(elem: Any) -> None:
|
||||||
applied = 0
|
|
||||||
stack = [script.upgrade_ops]
|
|
||||||
while stack:
|
|
||||||
elem = stack.pop(0)
|
|
||||||
if hasattr(elem, "ops"):
|
if hasattr(elem, "ops"):
|
||||||
stack.extend(elem.ops)
|
for child in elem.ops:
|
||||||
|
walk(child)
|
||||||
else:
|
else:
|
||||||
operations.invoke(elem)
|
flat.append(elem)
|
||||||
applied += 1
|
|
||||||
return applied
|
walk(script.upgrade_ops)
|
||||||
|
return flat
|
||||||
|
|
||||||
|
|
||||||
|
def _invoke_ops(connection: Connection, ops: list[Any]) -> int:
|
||||||
|
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
|
||||||
|
operations = Operations(MigrationContext.configure(connection, opts=opts))
|
||||||
|
for op in ops:
|
||||||
|
operations.invoke(op)
|
||||||
|
return len(ops)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_sql(ops: list[Any]) -> str:
|
||||||
|
"""Render operations as PostgreSQL DDL without touching a database (Alembic offline mode)."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
ctx = MigrationContext.configure(
|
||||||
|
dialect_name="postgresql",
|
||||||
|
opts={"as_sql": True, "output_buffer": buf, "literal_binds": True},
|
||||||
|
)
|
||||||
|
operations = Operations(ctx)
|
||||||
|
for op in ops:
|
||||||
|
operations.invoke(op)
|
||||||
|
return buf.getvalue().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_upgrade_ops(connection: Connection) -> int:
|
||||||
|
"""Apply ORM→DB diffs in-process without writing a revision file."""
|
||||||
|
return _invoke_ops(connection, _pending_ops(connection))
|
||||||
|
|
||||||
|
|
||||||
async def apply_model_drift() -> bool:
|
async def apply_model_drift() -> bool:
|
||||||
|
|
@ -458,6 +507,107 @@ async def run_manual_sql() -> None:
|
||||||
logger.info("applied manual migration %s", path.name)
|
logger.info("applied manual migration %s", path.name)
|
||||||
|
|
||||||
|
|
||||||
|
async def _pending_manual_files() -> list[str]:
|
||||||
|
"""Manual SQL files this database has not recorded yet. Read-only."""
|
||||||
|
files = sorted(p.name for p in (MIGRATIONS / "manual").glob("*.sql") if p.is_file())
|
||||||
|
schema = get_settings().db_default_schema
|
||||||
|
table = f"{schema}.{MANUAL_TABLE}" if schema else MANUAL_TABLE
|
||||||
|
async with get_engine().connect() as conn:
|
||||||
|
driver = (await conn.get_raw_connection()).driver_connection
|
||||||
|
if await driver.fetchval("SELECT to_regclass($1)", table) is None:
|
||||||
|
return files
|
||||||
|
applied = {r["filename"] for r in await driver.fetch(f"SELECT filename FROM {table}")}
|
||||||
|
return [f for f in files if f not in applied]
|
||||||
|
|
||||||
|
|
||||||
|
async def run_rbac_sync(*, dry_run: bool = False) -> list[str]:
|
||||||
|
"""Bring permission tags, system roles and bundles up to role/plugins.py.
|
||||||
|
|
||||||
|
Returns the SQL it ran (or would run). Empty once the database matches the code,
|
||||||
|
so running it on every boot is cheap.
|
||||||
|
"""
|
||||||
|
from role.plugins import RbacState, build_rbac_sql # local import: pulls in the app models
|
||||||
|
|
||||||
|
schema = get_settings().db_default_schema or None
|
||||||
|
prefix = f"{schema}." if schema else ""
|
||||||
|
async with get_engine().connect() as conn:
|
||||||
|
driver = (await conn.get_raw_connection()).driver_connection
|
||||||
|
if await driver.fetchval("SELECT to_regclass($1)", f"{prefix}roles") is None:
|
||||||
|
logger.info("rbac sync skipped: roles table does not exist yet")
|
||||||
|
return []
|
||||||
|
has_ledger = (
|
||||||
|
await driver.fetchval("SELECT to_regclass($1)", f"{prefix}{RBAC_LEDGER_TABLE}")
|
||||||
|
) is not None
|
||||||
|
async def column(sql: str) -> frozenset[Any]:
|
||||||
|
return frozenset(tuple(r) if len(r) > 1 else r[0] for r in await driver.fetch(sql))
|
||||||
|
|
||||||
|
state = RbacState(
|
||||||
|
tags=await column(f"SELECT tag_name FROM {prefix}permission_tags"),
|
||||||
|
roles=await column(f"SELECT role_name FROM {prefix}roles"),
|
||||||
|
bundles=await column(f"SELECT name FROM {prefix}permissions"),
|
||||||
|
ledger=(
|
||||||
|
await column(f"SELECT kind, key FROM {prefix}{RBAC_LEDGER_TABLE}")
|
||||||
|
if has_ledger
|
||||||
|
else frozenset()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
statements = build_rbac_sql(state, schema=schema)
|
||||||
|
if statements and not dry_run:
|
||||||
|
async with driver.transaction():
|
||||||
|
for statement in statements:
|
||||||
|
await driver.execute(statement)
|
||||||
|
logger.info("rbac sync applied %s statement(s)", len(statements))
|
||||||
|
return statements
|
||||||
|
|
||||||
|
|
||||||
|
async def sync(*, dry_run: bool = False, allow_drops: bool = False) -> str:
|
||||||
|
"""Every pending change in one pass: schema drift from all models, manual SQL, RBAC.
|
||||||
|
|
||||||
|
Returns the SQL as one reviewable script. `dry_run` executes nothing. Destructive
|
||||||
|
schema operations are listed as comments and skipped unless `allow_drops`.
|
||||||
|
"""
|
||||||
|
header = ["-- DRY RUN: nothing was executed"] if dry_run else []
|
||||||
|
# Rendered DDL names reflected tables without a schema, as `_run` resolves them.
|
||||||
|
header.append(f'SET search_path TO "{get_settings().db_default_schema or "public"}", public;')
|
||||||
|
out = list(header)
|
||||||
|
async with _lock():
|
||||||
|
pending: list[Any] = []
|
||||||
|
if await _schema_is_empty():
|
||||||
|
if dry_run:
|
||||||
|
out.append("-- empty database: sync would create every model table from metadata")
|
||||||
|
else:
|
||||||
|
await bootstrap_empty()
|
||||||
|
out.append("-- empty database: created every model table from metadata")
|
||||||
|
else:
|
||||||
|
pending = await _run(_pending_ops)
|
||||||
|
|
||||||
|
kept = [op for op in pending if allow_drops or not isinstance(op, _DESTRUCTIVE_OPS)]
|
||||||
|
skipped = [op for op in pending if not allow_drops and isinstance(op, _DESTRUCTIVE_OPS)]
|
||||||
|
if kept:
|
||||||
|
out += [f"-- schema: {len(kept)} operation(s) from the models", _render_sql(kept)]
|
||||||
|
if not dry_run:
|
||||||
|
await _run(lambda c: _invoke_ops(c, kept))
|
||||||
|
if skipped:
|
||||||
|
rendered = "\n".join(f"-- {line}" for line in _render_sql(skipped).splitlines())
|
||||||
|
header = f"-- skipped {len(skipped)} destructive operation(s); rerun with --allow-drops"
|
||||||
|
out += [header, rendered]
|
||||||
|
|
||||||
|
manual = await _pending_manual_files()
|
||||||
|
if manual:
|
||||||
|
verb = "pending (RBAC plan below assumes they did not run)" if dry_run else "applied"
|
||||||
|
out.append(f"-- manual SQL {verb}:\n" + "\n".join(f"-- {name}" for name in manual))
|
||||||
|
if not dry_run:
|
||||||
|
await run_manual_sql()
|
||||||
|
|
||||||
|
rbac = await run_rbac_sync(dry_run=dry_run)
|
||||||
|
if rbac:
|
||||||
|
out += [f"-- rbac: {len(rbac)} statement(s) from role/plugins.py", *rbac]
|
||||||
|
|
||||||
|
if len(out) == len(header):
|
||||||
|
out.append("-- database is in sync with the code")
|
||||||
|
return "\n\n".join(out) + "\n"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _lock() -> AsyncIterator[None]:
|
async def _lock() -> AsyncIterator[None]:
|
||||||
"""Advisory lock, so only one worker migrates when several boot at once."""
|
"""Advisory lock, so only one worker migrates when several boot at once."""
|
||||||
|
|
@ -492,6 +642,10 @@ async def migrate(*, autogen: bool | None = None, message: str = "auto") -> None
|
||||||
# ran above. Log and continue so the API can finish booting.
|
# ran above. Log and continue so the API can finish booting.
|
||||||
logger.exception("ORM drift apply failed; continuing boot: %s", exc)
|
logger.exception("ORM drift apply failed; continuing boot: %s", exc)
|
||||||
await run_manual_sql()
|
await run_manual_sql()
|
||||||
|
try:
|
||||||
|
await run_rbac_sync()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("RBAC sync failed; continuing boot: %s", exc)
|
||||||
logger.info("database at revision %s", await current())
|
logger.info("database at revision %s", await current())
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -510,10 +664,15 @@ def main(argv: Sequence[str] | None = None) -> None:
|
||||||
"current",
|
"current",
|
||||||
"head",
|
"head",
|
||||||
"stamp",
|
"stamp",
|
||||||
|
"sync",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
parser.add_argument("-m", "--message", default="auto", help="revision message")
|
parser.add_argument("-m", "--message", default="auto", help="revision message")
|
||||||
parser.add_argument("-r", "--revision", help="target revision")
|
parser.add_argument("-r", "--revision", help="target revision")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="sync: print the SQL, run nothing")
|
||||||
|
parser.add_argument(
|
||||||
|
"--allow-drops", action="store_true", help="sync: also drop tables, columns, indexes"
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
|
@ -539,6 +698,10 @@ def main(argv: Sequence[str] | None = None) -> None:
|
||||||
print(head())
|
print(head())
|
||||||
elif args.command == "stamp":
|
elif args.command == "stamp":
|
||||||
await stamp(args.revision or "head")
|
await stamp(args.revision or "head")
|
||||||
|
elif args.command == "sync":
|
||||||
|
if not args.dry_run:
|
||||||
|
await create_schemas()
|
||||||
|
print(await sync(dry_run=args.dry_run, allow_drops=args.allow_drops), end="")
|
||||||
finally:
|
finally:
|
||||||
await close_db()
|
await close_db()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ async def search_requisitions(
|
||||||
require_all=False,
|
require_all=False,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
q: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
top: int = Query(50, ge=1, le=100),
|
top: int = Query(50, ge=1, le=100),
|
||||||
job_post_id: uuid.UUID | None = Query(None),
|
job_post_id: uuid.UUID | None = Query(None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
|
@ -91,7 +91,7 @@ async def search_requisitions(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
service = RequisitionForm(session=session)
|
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})
|
return JSONResponse(content={"data": data, "status_code": 200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -99,6 +99,22 @@ async def search_requisitions(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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")
|
@router.get("/forms/requisition/fetch")
|
||||||
async def fetch_requisition_form(
|
async def fetch_requisition_form(
|
||||||
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
|
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from enum import Enum
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
import uuid
|
||||||
class EmploymentType(str,Enum):
|
class EmploymentType(str,Enum):
|
||||||
PERMANENT = "permanent"
|
PERMANENT = "permanent"
|
||||||
CONTRACT = "contract"
|
CONTRACT = "contract"
|
||||||
|
|
@ -10,6 +10,7 @@ class EmploymentType(str,Enum):
|
||||||
INTERNEE="internee"
|
INTERNEE="internee"
|
||||||
|
|
||||||
class Position(BaseModel):
|
class Position(BaseModel):
|
||||||
|
department_id:uuid.UUID
|
||||||
department:Optional[str]
|
department:Optional[str]
|
||||||
title:Optional[str]
|
title:Optional[str]
|
||||||
date:Optional[date]
|
date:Optional[date]
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@ import uuid
|
||||||
from datetime import datetime, date as Date, timezone
|
from datetime import datetime, date as Date, timezone
|
||||||
from typing import TYPE_CHECKING, Optional
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select
|
||||||
|
|
||||||
from candidate_forms.enums import EmploymentType
|
from candidate_forms.enums import EmploymentType
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from department.models import Department
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
@ -20,6 +20,11 @@ class Requisition(SQLModel, table=True):
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
|
||||||
department: Optional[str] = None
|
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
|
position_title: Optional[str] = None
|
||||||
date: Optional[Date] = None
|
date: Optional[Date] = None
|
||||||
date_needed: Optional[Date] = None
|
date_needed: Optional[Date] = None
|
||||||
|
|
@ -94,7 +99,7 @@ class Requisition(SQLModel, table=True):
|
||||||
async def search(
|
async def search(
|
||||||
cls,
|
cls,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
q: str | None = None,
|
search: str | None = None,
|
||||||
*,
|
*,
|
||||||
top: int = 50,
|
top: int = 50,
|
||||||
job_post_id=None,
|
job_post_id=None,
|
||||||
|
|
@ -120,7 +125,7 @@ class Requisition(SQLModel, table=True):
|
||||||
if except_uid is not None:
|
if except_uid is not None:
|
||||||
held = held.where(JobPosts.id != except_uid)
|
held = held.where(JobPosts.id != except_uid)
|
||||||
statement = statement.where(cls.id.notin_(held))
|
statement = statement.where(cls.id.notin_(held))
|
||||||
term = (q or "").strip()
|
term = (search or "").strip()
|
||||||
if term:
|
if term:
|
||||||
like = f"%{term}%"
|
like = f"%{term}%"
|
||||||
statement = statement.where(
|
statement = statement.where(
|
||||||
|
|
@ -131,6 +136,32 @@ class Requisition(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return list(result.scalars().all())
|
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
|
@classmethod
|
||||||
async def insert_form(cls, session: AsyncSession, fields: dict):
|
async def insert_form(cls, session: AsyncSession, fields: dict):
|
||||||
position = fields.get("position") if fields.get("position") else {}
|
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 {}
|
referral = fields.get("refferal_by") if fields.get("refferal_by") else {}
|
||||||
row = cls(
|
row = cls(
|
||||||
department=position.get("department") if position.get("department") else None,
|
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,
|
position_title=position.get("title") if position.get("title") else None,
|
||||||
date=position.get("date") if position.get("date") 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,
|
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 {}
|
position = fields.get("position") if fields.get("position") else {}
|
||||||
if "department" in position:
|
if "department" in position:
|
||||||
row.department = position.get("department") if position.get("department") else None
|
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:
|
if "title" in position:
|
||||||
row.position_title = position.get("title") if position.get("title") else None
|
row.position_title = position.get("title") if position.get("title") else None
|
||||||
if "date" in position:
|
if "date" in position:
|
||||||
|
|
@ -391,3 +425,4 @@ class CandidateForms(SQLModel, table=True):
|
||||||
|
|
||||||
|
|
||||||
import users.models as _users_models # noqa: E402, F401
|
import users.models as _users_models # noqa: E402, F401
|
||||||
|
import department.models as _department_models # noqa: E402, F401
|
||||||
|
|
@ -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")
|
FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ def serialize_requisition_option(row) -> dict:
|
||||||
"id": str(row.id) if row.id else None,
|
"id": str(row.id) if row.id else None,
|
||||||
"title": row.position_title,
|
"title": row.position_title,
|
||||||
"department": row.department,
|
"department": row.department,
|
||||||
|
"department_id": str(row.department_id) if row.department_id else None,
|
||||||
"label": f"{title or 'Untitled'} - {department or '—'}",
|
"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,
|
"id": str(row.id) if row.id else None,
|
||||||
"position": {
|
"position": {
|
||||||
"department": row.department,
|
"department": row.department,
|
||||||
|
"department_id": str(row.department_id) if row.department_id else None,
|
||||||
"title": row.position_title,
|
"title": row.position_title,
|
||||||
"date": _date(row.date),
|
"date": _date(row.date),
|
||||||
"date_needed": _date(row.date_needed),
|
"date_needed": _date(row.date_needed),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ from candidate_forms.plugins import (
|
||||||
combined_summary,
|
combined_summary,
|
||||||
normalize_fields,
|
normalize_fields,
|
||||||
normalize_sections,
|
normalize_sections,
|
||||||
|
_as_uuid,
|
||||||
|
_user_id,
|
||||||
|
_aware,
|
||||||
|
_stage_value,
|
||||||
|
_recommendation,
|
||||||
|
_score_sections,
|
||||||
|
_score_fields,
|
||||||
)
|
)
|
||||||
from candidate_forms.serializers import (
|
from candidate_forms.serializers import (
|
||||||
serialize_form,
|
serialize_form,
|
||||||
|
|
@ -32,63 +39,6 @@ from users.permissions import is_admin, is_hiring_manager
|
||||||
logger = logging.getLogger("candidate_forms")
|
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:
|
class CandidateForm:
|
||||||
def __init__(self, session: AsyncSession):
|
def __init__(self, session: AsyncSession):
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
@ -406,6 +356,7 @@ class RequisitionForm:
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
||||||
async def create_form(self, payload, current_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)
|
payload["created_by"] = _user_id(current_user)
|
||||||
row = await Requisition.insert_form(self.session, payload)
|
row = await Requisition.insert_form(self.session, payload)
|
||||||
return serialize_requisition(row)
|
return serialize_requisition(row)
|
||||||
|
|
@ -435,8 +386,13 @@ class RequisitionForm:
|
||||||
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
|
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
|
||||||
return [serialize_requisition(r) for r in rows]
|
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(
|
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]
|
return [serialize_requisition_option(r) for r in rows]
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -11,6 +11,7 @@ from dotenv import load_dotenv
|
||||||
from taskiq import TaskiqEvents
|
from taskiq import TaskiqEvents
|
||||||
|
|
||||||
from db_setup import session_scope
|
from db_setup import session_scope
|
||||||
|
import department.models # noqa: F401
|
||||||
from inbox.models import MailboxSyncRun
|
from inbox.models import MailboxSyncRun
|
||||||
from inbox.views import Email
|
from inbox.views import Email
|
||||||
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY
|
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY
|
||||||
|
|
|
||||||
|
|
@ -1359,7 +1359,7 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
return row
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids) -> dict[str, int]:
|
async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids,search=None,top=None,limit=None) -> dict[str, int]:
|
||||||
"""Resolve {job_post_id: applicant_count} for a page of rows in a single query.
|
"""Resolve {job_post_id: applicant_count} for a page of rows in a single query.
|
||||||
|
|
||||||
Counts Inbox_Messages rows, not Inbox rows: one message fans out to several
|
Counts Inbox_Messages rows, not Inbox rows: one message fans out to several
|
||||||
|
|
@ -1373,6 +1373,12 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
.where(cls.assigned_job_post_id.in_(uids))
|
.where(cls.assigned_job_post_id.in_(uids))
|
||||||
.group_by(cls.assigned_job_post_id)
|
.group_by(cls.assigned_job_post_id)
|
||||||
)
|
)
|
||||||
|
if search:
|
||||||
|
result = result.where(cls.message_from.ilike(f"%{search}%"))
|
||||||
|
if top:
|
||||||
|
result = result.limit(top)
|
||||||
|
if limit:
|
||||||
|
result = result.limit(limit)
|
||||||
return {str(job_id): int(n) for job_id, n in result.all()}
|
return {str(job_id): int(n) for job_id, n in result.all()}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -2152,6 +2158,109 @@ class AtsResults(SQLModel, table=True):
|
||||||
grouped.setdefault(row.form_data_id, []).append(row)
|
grouped.setdefault(row.form_data_id, []).append(row)
|
||||||
return grouped
|
return grouped
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def latest_per_candidate_for_job(
|
||||||
|
cls, session: AsyncSession, job_post_id, search=None, top=None, limit=None, skip=None,
|
||||||
|
):
|
||||||
|
"""Suggested candidates for one job: the newest score per person, best first.
|
||||||
|
|
||||||
|
A person is whichever identity the row carries — form_data_id,
|
||||||
|
candidate_id or user_id (one is set at a time) — so DISTINCT ON their
|
||||||
|
COALESCE, newest created_at winning. Rows carrying none are skipped.
|
||||||
|
`search` matches the candidate's name, email, title or company; `top` and
|
||||||
|
`limit` cap how many rows come back; `skip` is the page offset. Returns
|
||||||
|
(row, user_name, user_email, form_name, form_email, candidate).
|
||||||
|
"""
|
||||||
|
from g_sheet.models import FormData
|
||||||
|
from job.candidate.models import Candidates
|
||||||
|
|
||||||
|
jid = cls._as_uuid(job_post_id)
|
||||||
|
if jid is None:
|
||||||
|
return []
|
||||||
|
identity = func.coalesce(cls.form_data_id, cls.candidate_id, cls.user_id)
|
||||||
|
latest = (
|
||||||
|
select(cls.id)
|
||||||
|
.where(cls.job_post_id == jid, identity.is_not(None))
|
||||||
|
.distinct(identity)
|
||||||
|
.order_by(identity.desc(), cls.created_at.desc())
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
statement = (
|
||||||
|
select(cls, Users.name, Users.email, FormData.name, FormData.candidate_email, Candidates)
|
||||||
|
.join(latest, cls.id == latest.c.id)
|
||||||
|
.outerjoin(Users, cls.user_id == Users.id)
|
||||||
|
.outerjoin(FormData, cls.form_data_id == FormData.id)
|
||||||
|
.outerjoin(Candidates, cls.candidate_id == Candidates.id)
|
||||||
|
.order_by(cls.overall_score.desc(), cls.created_at.desc(),cls.id.desc())
|
||||||
|
)
|
||||||
|
if search:
|
||||||
|
like = f"%{search.strip()}%"
|
||||||
|
statement = statement.where(
|
||||||
|
or_(
|
||||||
|
Users.name.ilike(like),
|
||||||
|
Users.email.ilike(like),
|
||||||
|
FormData.name.ilike(like),
|
||||||
|
FormData.candidate_email.ilike(like),
|
||||||
|
Candidates.candidate_name.ilike(like),
|
||||||
|
Candidates.candidate_email.ilike(like),
|
||||||
|
Candidates.job_title.ilike(like),
|
||||||
|
Candidates.current_company.ilike(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if skip:
|
||||||
|
statement = statement.offset(skip)
|
||||||
|
if top:
|
||||||
|
statement = statement.limit(top)
|
||||||
|
if limit:
|
||||||
|
statement = statement.limit(limit)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def count_suggested_for_job(cls, session: AsyncSession, job_post_id, search=None) -> int:
|
||||||
|
"""How many suggested candidates match `search` — the paged list's total.
|
||||||
|
|
||||||
|
Same rows as latest_per_candidate_for_job before its offset and limit.
|
||||||
|
"""
|
||||||
|
from g_sheet.models import FormData
|
||||||
|
from job.candidate.models import Candidates
|
||||||
|
|
||||||
|
jid = cls._as_uuid(job_post_id)
|
||||||
|
if jid is None:
|
||||||
|
return 0
|
||||||
|
identity = func.coalesce(cls.form_data_id, cls.candidate_id, cls.user_id)
|
||||||
|
latest = (
|
||||||
|
select(cls.id)
|
||||||
|
.where(cls.job_post_id == jid, identity.is_not(None))
|
||||||
|
.distinct(identity)
|
||||||
|
.order_by(identity.desc(), cls.created_at.desc(),cls.id.desc())
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
statement = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(cls)
|
||||||
|
.join(latest, cls.id == latest.c.id)
|
||||||
|
.outerjoin(Users, cls.user_id == Users.id)
|
||||||
|
.outerjoin(FormData, cls.form_data_id == FormData.id)
|
||||||
|
.outerjoin(Candidates, cls.candidate_id == Candidates.id)
|
||||||
|
)
|
||||||
|
if search:
|
||||||
|
like = f"%{search.strip()}%"
|
||||||
|
statement = statement.where(
|
||||||
|
or_(
|
||||||
|
Users.name.ilike(like),
|
||||||
|
Users.email.ilike(like),
|
||||||
|
FormData.name.ilike(like),
|
||||||
|
FormData.candidate_email.ilike(like),
|
||||||
|
Candidates.candidate_name.ilike(like),
|
||||||
|
Candidates.candidate_email.ilike(like),
|
||||||
|
Candidates.job_title.ilike(like),
|
||||||
|
Candidates.current_company.ilike(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def job_ids_by_emails(cls, session: AsyncSession, emails) -> dict:
|
async def job_ids_by_emails(cls, session: AsyncSession, emails) -> dict:
|
||||||
"""{email: {job_post_id, ...}} for any prior ATS score of these people."""
|
"""{email: {job_post_id, ...}} for any prior ATS score of these people."""
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,7 @@ class HiringCostCreate(BaseModel):
|
||||||
class JobUpdate(BaseModel):
|
class JobUpdate(BaseModel):
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
department: str | None = None
|
department: str | None = None
|
||||||
|
department_id: UUID | None = None
|
||||||
location: str | None = None
|
location: str | None = None
|
||||||
employment_type: str | None = None
|
employment_type: str | None = None
|
||||||
vacancies: int | None = None
|
vacancies: int | None = None
|
||||||
|
|
@ -152,7 +153,6 @@ class JobUpdate(BaseModel):
|
||||||
experience_min: int | None = None
|
experience_min: int | None = None
|
||||||
experience_max: int | None = None
|
experience_max: int | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
current_recruiter_id: UUID | None = None
|
|
||||||
current_recruiter_ids: list[UUID] | None = None
|
current_recruiter_ids: list[UUID] | None = None
|
||||||
hiring_manager_id: UUID | None = None
|
hiring_manager_id: UUID | None = None
|
||||||
requisition_id: UUID | None = None
|
requisition_id: UUID | None = None
|
||||||
|
|
@ -937,13 +937,16 @@ async def fetch_requisition_statuses(
|
||||||
@router.get("/jobs/status-history/fetch")
|
@router.get("/jobs/status-history/fetch")
|
||||||
async def fetch_job_status_history(
|
async def fetch_job_status_history(
|
||||||
job_post_id:str=Query(...),
|
job_post_id:str=Query(...),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
top: int | None = Query(10, ge=1, le=500),
|
||||||
|
limit: int | None = Query(None, ge=1),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Who changed requisition_status on one job, from what, to what, and when."""
|
"""Who changed requisition_status on one job, from what, to what, and when."""
|
||||||
try:
|
try:
|
||||||
service=JobPost(session=session)
|
service=JobPost(session=session)
|
||||||
data=await service.fetch_status_history(job_post_id)
|
data=await service.fetch_status_history(job_post_id,search,top,limit)
|
||||||
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -983,6 +986,29 @@ async def fetch_jobs(
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/profile/fetch")
|
||||||
|
async def fetch_job_profile(
|
||||||
|
job_post_id: str = Query(...),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
top: int | None = Query(None, ge=1),
|
||||||
|
limit: int | None = Query(None, ge=1),
|
||||||
|
skip: int | None = Query(None, ge=0),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Job profile page: the job row, suggested candidates (newest ats_results row
|
||||||
|
per person, best score first) and the Suggested / Top Match header stats.
|
||||||
|
`total` is how many suggested candidates match `search`, across all pages."""
|
||||||
|
try:
|
||||||
|
service=JobPost(session=session)
|
||||||
|
data=await service.fetch_job_profile(job_post_id,current_user=current_user,search=search,top=top,limit=limit,skip=skip)
|
||||||
|
return JSONResponse(content={"data":data,"total":data["total"],"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/jobs/export")
|
@router.get("/jobs/export")
|
||||||
async def export_jobs(
|
async def export_jobs(
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ JOB_ASSIGNMENT_ROLES = {
|
||||||
"hiring_manager": EnumRoles.HIRING_MANAGER,
|
"hiring_manager": EnumRoles.HIRING_MANAGER,
|
||||||
}
|
}
|
||||||
JOB_OWNER_COLUMN = {
|
JOB_OWNER_COLUMN = {
|
||||||
"primary_recruiter": "current_recruiter_id",
|
"primary_recruiter": "current_recruiter_ids",
|
||||||
"hiring_manager": "hiring_manager_id",
|
"hiring_manager": "hiring_manager_id",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1143,6 +1143,28 @@ class Candidates(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def latest_completed_for_job_by_emails(cls, session: AsyncSession, job_id, emails) -> dict:
|
||||||
|
"""{lower(email): newest completed row} against one job — the batch form of
|
||||||
|
get_completed_by_email_job, for scores whose identity is a user or form row."""
|
||||||
|
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||||
|
jid = cls._as_uuid(job_id)
|
||||||
|
if not lowers or jid is None:
|
||||||
|
return {}
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(
|
||||||
|
func.lower(cls.candidate_email).in_(lowers),
|
||||||
|
cls.job_id == jid,
|
||||||
|
cls.status == "completed",
|
||||||
|
)
|
||||||
|
.order_by(cls.updated_at.desc())
|
||||||
|
)
|
||||||
|
out = {}
|
||||||
|
for row in result.scalars():
|
||||||
|
out.setdefault((row.candidate_email or "").strip().lower(), row)
|
||||||
|
return out
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_by_emails(cls, session: AsyncSession, emails):
|
async def list_by_emails(cls, session: AsyncSession, emails):
|
||||||
"""Scored `candidates` rows for these addresses (ATS, not pipeline stage)."""
|
"""Scored `candidates` rows for these addresses (ATS, not pipeline stage)."""
|
||||||
|
|
@ -1279,9 +1301,7 @@ class Interviews(SQLModel, table=True):
|
||||||
interview_type: str = Field(default="")
|
interview_type: str = Field(default="")
|
||||||
interview_status: str = Field(default="")
|
interview_status: str = Field(default="")
|
||||||
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
|
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
|
||||||
# Optional denorm so Recruiter Hub can join interviews → job_posts.current_recruiter_id
|
|
||||||
# without walking inbox. Filled on create from the application's assigned job;
|
|
||||||
# migration 011 added the columns. user_id is the candidate, not the recruiter.
|
|
||||||
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||||
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
||||||
graph_event_id: str | None = Field(default=None)
|
graph_event_id: str | None = Field(default=None)
|
||||||
|
|
@ -1342,9 +1362,6 @@ class Interviews(SQLModel, table=True):
|
||||||
@classmethod
|
@classmethod
|
||||||
def scoped_to_recruiter(cls, statement, recruiter_id):
|
def scoped_to_recruiter(cls, statement, recruiter_id):
|
||||||
"""Restrict an Interviews select to the recruiter who owns the job.
|
"""Restrict an Interviews select to the recruiter who owns the job.
|
||||||
|
|
||||||
COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id)
|
|
||||||
→ job_posts.current_recruiter_id. Interviews with no job drop out.
|
|
||||||
"""
|
"""
|
||||||
from inbox.models import Inbox, Inbox_Messages
|
from inbox.models import Inbox, Inbox_Messages
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from job.job_post.enums import RequisitionStatus
|
||||||
|
|
||||||
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
|
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
|
||||||
from candidate_forms.models import Requisition
|
from candidate_forms.models import Requisition
|
||||||
|
from department.models import Department
|
||||||
from users.models import Users
|
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)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
title: str = Field(index=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(
|
user: Optional["Users"] = Relationship(
|
||||||
back_populates="job_posts",
|
back_populates="job_posts",
|
||||||
|
|
@ -54,11 +60,7 @@ class JobPosts(SQLModel, table=True):
|
||||||
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||||
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
|
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
|
||||||
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||||
# Who is working the req now (swappable). History lives in job_assignments
|
|
||||||
# with assignment_role=primary_recruiter; this column is the first / primary
|
|
||||||
# pointer so existing joins keep working. current_recruiter_ids is the full
|
|
||||||
# list (UUID strings) so more than one recruiter can sit on the same job.
|
|
||||||
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
|
||||||
current_recruiter_ids: list[str] = Field(
|
current_recruiter_ids: list[str] = Field(
|
||||||
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"},
|
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"},
|
||||||
)
|
)
|
||||||
|
|
@ -88,59 +90,36 @@ class JobPosts(SQLModel, table=True):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def recruiter_ids_of(row) -> list[str]:
|
def recruiter_ids_of(row) -> list[str]:
|
||||||
"""UUID strings currently assigned as recruiters on a job row or mapping.
|
"""The job's recruiters as UUID strings — current_recruiter_ids, nothing else.
|
||||||
|
|
||||||
Prefers current_recruiter_ids; falls back to current_recruiter_id so a
|
Strings because every consumer keys names off str(uuid). Works on a JobPosts
|
||||||
row that has not been backfilled still maps to one person.
|
row and on a RowMapping from fetch_job_stats; both allow attribute access.
|
||||||
"""
|
"""
|
||||||
if isinstance(row, dict):
|
return [str(i) for i in (getattr(row, "current_recruiter_ids", None) or [])]
|
||||||
raw = row.get("current_recruiter_ids")
|
|
||||||
fallback = row.get("current_recruiter_id")
|
|
||||||
else:
|
|
||||||
raw = getattr(row, "current_recruiter_ids", None)
|
|
||||||
fallback = getattr(row, "current_recruiter_id", None)
|
|
||||||
out: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for item in raw or []:
|
|
||||||
uid = JobPosts._as_uuid(item)
|
|
||||||
if uid is None:
|
|
||||||
continue
|
|
||||||
key = str(uid)
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
out.append(key)
|
|
||||||
if not out:
|
|
||||||
uid = JobPosts._as_uuid(fallback)
|
|
||||||
if uid is not None:
|
|
||||||
out.append(str(uid))
|
|
||||||
return out
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def has_recruiter(cls, recruiter_id):
|
def has_recruiter(cls, recruiter_id):
|
||||||
"""SQL: this recruiter is the primary pointer or in current_recruiter_ids."""
|
"""SQL: this recruiter is in current_recruiter_ids."""
|
||||||
uid = recruiter_id if isinstance(recruiter_id, uuid.UUID) else cls._as_uuid(recruiter_id)
|
uid = recruiter_id if isinstance(recruiter_id, uuid.UUID) else cls._as_uuid(recruiter_id)
|
||||||
if uid is None:
|
if uid is None:
|
||||||
return false()
|
return false()
|
||||||
return or_(
|
return cls.current_recruiter_ids.contains([str(uid)])
|
||||||
cls.current_recruiter_id == uid,
|
|
||||||
cls.current_recruiter_ids.contains([str(uid)]),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def no_recruiters(cls):
|
def no_recruiters(cls):
|
||||||
"""SQL: neither the pointer nor the JSON list names anyone."""
|
"""SQL: current_recruiter_ids names nobody — NULL or [].
|
||||||
return and_(
|
|
||||||
cls.current_recruiter_id.is_(None),
|
coalesce covers both, so this must not be ANDed with an IS NULL test:
|
||||||
func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0,
|
an empty list is not NULL and would fall out of the result.
|
||||||
)
|
"""
|
||||||
|
return func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
|
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
|
||||||
uid = cls._as_uuid(record_id)
|
uid = cls._as_uuid(record_id)
|
||||||
if uid is None:
|
if uid is None:
|
||||||
return None
|
return None
|
||||||
result = await session.execute(select(cls).where(cls.id == uid))
|
result = await session.execute(select(cls).where(cls.id == uid).order_by(cls.created_at.desc(),cls.id.desc()))
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -501,9 +480,9 @@ class JobPosts(SQLModel, table=True):
|
||||||
.subquery("job_reapplied")
|
.subquery("job_reapplied")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Alias so this join does not collide with the Users join inside
|
# No recruiter join here: current_recruiter_ids is a JSONB array, so a join
|
||||||
# the manual-upload subquery above.
|
# on it would emit one stats row per recruiter and multiply the counts.
|
||||||
Recruiter=aliased(Users)
|
# The service resolves recruiter names from that column instead.
|
||||||
statement = (
|
statement = (
|
||||||
select(
|
select(
|
||||||
cls.id.label("job_post_id"),
|
cls.id.label("job_post_id"),
|
||||||
|
|
@ -511,10 +490,8 @@ class JobPosts(SQLModel, table=True):
|
||||||
cls.department,
|
cls.department,
|
||||||
cls.location,
|
cls.location,
|
||||||
cls.requisition_status,
|
cls.requisition_status,
|
||||||
cls.current_recruiter_id,
|
|
||||||
cls.current_recruiter_ids,
|
cls.current_recruiter_ids,
|
||||||
cls.created_at,
|
cls.created_at,
|
||||||
Recruiter.name.label("recruiter_name"),
|
|
||||||
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
|
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
|
||||||
func.coalesce(stats.c.shortlisting, 0).label("shortlisting"),
|
func.coalesce(stats.c.shortlisting, 0).label("shortlisting"),
|
||||||
func.coalesce(stats.c.screened, 0).label("screened"),
|
func.coalesce(stats.c.screened, 0).label("screened"),
|
||||||
|
|
@ -530,7 +507,6 @@ class JobPosts(SQLModel, table=True):
|
||||||
.select_from(cls)
|
.select_from(cls)
|
||||||
.outerjoin(stats, stats.c.job_post_id == cls.id)
|
.outerjoin(stats, stats.c.job_post_id == cls.id)
|
||||||
.outerjoin(reapplied_stats, reapplied_stats.c.job_post_id == cls.id)
|
.outerjoin(reapplied_stats, reapplied_stats.c.job_post_id == cls.id)
|
||||||
.outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id)
|
|
||||||
.where(cls.is_deleted == False) # noqa: E712
|
.where(cls.is_deleted == False) # noqa: E712
|
||||||
)
|
)
|
||||||
if active_only:
|
if active_only:
|
||||||
|
|
@ -885,15 +861,22 @@ class JobPostStatusHistory(SQLModel, table=True):
|
||||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def fetch_by_job(cls, session: AsyncSession, job_post_id):
|
async def fetch_by_job(cls, session: AsyncSession, job_post_id,search=None,top=None,limit=None):
|
||||||
uid = JobPosts._as_uuid(job_post_id)
|
uid = JobPosts._as_uuid(job_post_id)
|
||||||
if uid is None:
|
if uid is None:
|
||||||
return []
|
return []
|
||||||
result = await session.execute(
|
statement = (
|
||||||
select(cls)
|
select(cls)
|
||||||
.where(cls.job_post_id == uid)
|
.where(cls.job_post_id == uid)
|
||||||
.order_by(cls.created_at.desc(), cls.id.desc())
|
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||||
)
|
)
|
||||||
|
if search:
|
||||||
|
statement = statement.where(cls.from_status.ilike(f"%{search}%") or cls.to_status.ilike(f"%{search}%"))
|
||||||
|
if top:
|
||||||
|
statement = statement.limit(top)
|
||||||
|
if limit:
|
||||||
|
statement = statement.limit(limit)
|
||||||
|
result = await session.execute(statement)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -984,4 +967,7 @@ class SocialPlatform(SQLModel, table=True):
|
||||||
# Requisition must be registered before Users relationships trigger mapper
|
# Requisition must be registered before Users relationships trigger mapper
|
||||||
# configure — JobPosts.requisition_id FKs to app.requisitions.
|
# configure — JobPosts.requisition_id FKs to app.requisitions.
|
||||||
import candidate_forms.models as _requisition_models # noqa: E402, F401
|
import candidate_forms.models as _requisition_models # noqa: E402, F401
|
||||||
import users.models as _users_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
|
||||||
|
|
@ -281,3 +281,75 @@ async def list_buffer_channels() -> list[dict]:
|
||||||
"organization_name": org.get("name"),
|
"organization_name": org.get("name"),
|
||||||
})
|
})
|
||||||
return channels
|
return channels
|
||||||
|
|
||||||
|
|
||||||
|
# ats_results.band labels, best first — the same cut-offs CandidateView._recommendation writes.
|
||||||
|
MATCH_BANDS = ("Strong Match", "Potential Match", "Weak Match")
|
||||||
|
TOP_MATCH_BAND = MATCH_BANDS[0]
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_source(inbox_id, form_data_id, candidate_source=None) -> str:
|
||||||
|
"""Where a suggested candidate's score came from: inbox | form | upload | bank."""
|
||||||
|
if inbox_id is not None:
|
||||||
|
return "inbox"
|
||||||
|
if form_data_id is not None:
|
||||||
|
return "form"
|
||||||
|
return (candidate_source or "").strip() or "upload"
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_key(value) -> str:
|
||||||
|
return " ".join(re.sub(r"[^a-z0-9+#]+", " ", str(value or "").lower()).split())
|
||||||
|
|
||||||
|
|
||||||
|
def optional_skill_hits(optional_skills, matched_keywords) -> list[str]:
|
||||||
|
"""Job optional skills the candidate's matched keywords cover, in the job's order.
|
||||||
|
|
||||||
|
A hit is an exact normalized match, or one side containing the other as whole
|
||||||
|
words ("Salesforce" covers "CRM (Salesforce)"). Keywords are verified against the
|
||||||
|
resume upstream, so this only has to line up two spellings of the same skill.
|
||||||
|
"""
|
||||||
|
keys = [k for k in (_skill_key(m) for m in matched_keywords or []) if k]
|
||||||
|
hits = []
|
||||||
|
for skill in optional_skills or []:
|
||||||
|
target = _skill_key(skill)
|
||||||
|
if not target or skill in hits:
|
||||||
|
continue
|
||||||
|
padded = f" {target} "
|
||||||
|
if any(k == target or f" {k} " in padded or padded in f" {k} " for k in keys):
|
||||||
|
hits.append(skill)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_summary(candidates) -> dict:
|
||||||
|
"""Header stats for a job's suggested candidates: count, top-band count, best score."""
|
||||||
|
bands = {band: 0 for band in MATCH_BANDS}
|
||||||
|
scores = []
|
||||||
|
for c in candidates or []:
|
||||||
|
if c.get("band") in bands:
|
||||||
|
bands[c["band"]] += 1
|
||||||
|
if c.get("match_score") is not None:
|
||||||
|
scores.append(c["match_score"])
|
||||||
|
return {
|
||||||
|
"suggested": len(candidates or []),
|
||||||
|
"top_match": bands[TOP_MATCH_BAND],
|
||||||
|
"top_score": max(scores) if scores else None,
|
||||||
|
"bands": bands,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def job_people_of(row, people) -> dict:
|
||||||
|
"""One job's slice of a page-wide {"recruiters": …, "hiring_manager": …} lookup.
|
||||||
|
|
||||||
|
Users.job_people resolves every id on the page in two queries; this picks out
|
||||||
|
the names belonging to one row, keeping the two roles in their own maps.
|
||||||
|
"""
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
recruiters = (people or {}).get("recruiters") or {}
|
||||||
|
managers = (people or {}).get("hiring_manager") or {}
|
||||||
|
manager_id = getattr(row, "hiring_manager_id", None)
|
||||||
|
key = str(manager_id) if manager_id else None
|
||||||
|
return {
|
||||||
|
"recruiters": {rid: recruiters.get(rid) for rid in JobPosts.recruiter_ids_of(row)},
|
||||||
|
"hiring_manager": {key: managers[key]} if key and key in managers else {},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,20 @@ def serialize_job_post_title(row) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def _recruiter_payload(row, names=None):
|
def _recruiter_payload(row, names=None):
|
||||||
"""List of recruiter ids plus mapped names; first id stays the legacy pointer."""
|
"""Recruiter ids plus their mapped names, for the payloads that still send the
|
||||||
|
flat shape (serialize_job_post, serialize_job_stats)."""
|
||||||
names = names or {}
|
names = names or {}
|
||||||
ids = JobPosts.recruiter_ids_of(row)
|
ids = JobPosts.recruiter_ids_of(row)
|
||||||
mapped = [names.get(i) for i in ids]
|
mapped = [names.get(i) for i in ids]
|
||||||
first = ids[0] if ids else None
|
# recruiter_name is the singular legacy field: the first id that resolved.
|
||||||
|
recruiter_name = None
|
||||||
|
for n in mapped:
|
||||||
|
if n:
|
||||||
|
recruiter_name = n
|
||||||
|
break
|
||||||
return {
|
return {
|
||||||
"current_recruiter_id": first,
|
|
||||||
"current_recruiter_ids": ids,
|
"current_recruiter_ids": ids,
|
||||||
"recruiter_name": next((n for n in mapped if n), None),
|
"recruiter_name": recruiter_name,
|
||||||
"recruiter_names": [n for n in mapped if n],
|
"recruiter_names": [n for n in mapped if n],
|
||||||
"recruiters": [{"id": i, "name": names.get(i)} for i in ids],
|
"recruiters": [{"id": i, "name": names.get(i)} for i in ids],
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +46,7 @@ def serialize_job_post(row, *, names=None) -> dict:
|
||||||
"title": row.title,
|
"title": row.title,
|
||||||
# Talent Pool / candidate filters key off attached job_posts.department.
|
# Talent Pool / candidate filters key off attached job_posts.department.
|
||||||
"department": row.department or None,
|
"department": row.department or None,
|
||||||
|
"department_id": str(row.department_id) if row.department_id else None,
|
||||||
"employment_type": row.employment_type,
|
"employment_type": row.employment_type,
|
||||||
"location": row.location,
|
"location": row.location,
|
||||||
"experience_min": row.experience_min,
|
"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:
|
def serialize_job_row(row, *, people=None, applicant_count=0) -> dict:
|
||||||
"""Requisition view of a job post, for the Jobs screen.
|
"""Requisition view of a job post, for the Jobs screen and the job profile.
|
||||||
|
|
||||||
Deliberately separate from serialize_job_post: that payload is shared by the
|
Deliberately separate from serialize_job_post: that payload is shared by the
|
||||||
inbox, candidate and matching paths. department is the one shared field —
|
inbox, candidate and matching paths. department is the one shared field —
|
||||||
talent-pool filters key off it on attached job_posts.
|
talent-pool filters key off it on attached job_posts.
|
||||||
|
|
||||||
|
`people` is Users.job_people's shape — {"recruiters": {id: name},
|
||||||
|
"hiring_manager": {id: name}} — so the two roles stay apart in the response.
|
||||||
"""
|
"""
|
||||||
req = getattr(row, "requisition", None)
|
req = getattr(row, "requisition", None)
|
||||||
payload = _recruiter_payload(row, names)
|
people = people or {}
|
||||||
if recruiter_name and not payload["recruiter_name"]:
|
recruiters = people.get("recruiters") or {}
|
||||||
payload["recruiter_name"] = recruiter_name
|
hiring_manager = people.get("hiring_manager") or None
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"title": row.title,
|
"title": row.title,
|
||||||
"department": row.department 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,
|
"location": row.location,
|
||||||
"employment_type": row.employment_type,
|
"employment_type": row.employment_type,
|
||||||
"vacancies": row.vacancies,
|
"vacancies": row.vacancies,
|
||||||
"platform": row.platform or None,
|
"platform": row.platform or None,
|
||||||
# Two different lifecycles, never conflate: requisition_status is hiring
|
|
||||||
# (open/closed/on_hold), status is Buffer publishing (draft/scheduled/...).
|
|
||||||
"requisition_status": row.requisition_status,
|
"requisition_status": row.requisition_status,
|
||||||
"status": row.status,
|
"status": row.status,
|
||||||
"experience_min": row.experience_min,
|
"experience_min": row.experience_min,
|
||||||
|
|
@ -99,9 +109,6 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
|
||||||
"description": row.description,
|
"description": row.description,
|
||||||
"is_active": row.is_active,
|
"is_active": row.is_active,
|
||||||
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
|
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
|
||||||
**payload,
|
|
||||||
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
|
|
||||||
"hiring_manager_name": hiring_manager_name,
|
|
||||||
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
|
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
|
||||||
"requisition_title": req.position_title if req else None,
|
"requisition_title": req.position_title if req else None,
|
||||||
"requisition_department": req.department if req else None,
|
"requisition_department": req.department if req else None,
|
||||||
|
|
@ -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_by_name": row.user.name if getattr(row, "user", None) else None,
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
"recruiters": recruiters,
|
||||||
|
"hiring_manager": hiring_manager,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -155,3 +164,30 @@ def serialize_status_history(row, *, changed_by_name=None) -> dict:
|
||||||
"actor_kind": row.actor_kind,
|
"actor_kind": row.actor_kind,
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_suggested_candidate(row, *, name=None, email=None, candidate=None, source=None, optional_matched=None) -> dict:
|
||||||
|
"""One suggested candidate on the job profile: the newest ats_results row for a
|
||||||
|
person, with profile fields from its `candidates` row when one exists."""
|
||||||
|
score = row.overall_score
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"user_id": str(row.user_id) if row.user_id else None,
|
||||||
|
"candidate_id": str(row.candidate_id) if row.candidate_id else None,
|
||||||
|
"form_data_id": str(row.form_data_id) if row.form_data_id else None,
|
||||||
|
"inbox_id": row.inbox_id,
|
||||||
|
"name": name or (candidate.candidate_name if candidate else None) or email or None,
|
||||||
|
"email": email or (candidate.candidate_email if candidate else None) or None,
|
||||||
|
"current_title": candidate.job_title if candidate else None,
|
||||||
|
"current_company": candidate.current_company if candidate else None,
|
||||||
|
"years_experience": candidate.years_experience if candidate else None,
|
||||||
|
"match_score": round(score) if score is not None else None,
|
||||||
|
"band": row.band or None,
|
||||||
|
"matched_keywords": list(candidate.matched_keywords or []) if candidate else [],
|
||||||
|
"missing_keywords": list(candidate.missing_keywords or []) if candidate else [],
|
||||||
|
"optional_matched": list(optional_matched or []),
|
||||||
|
"summary": (candidate.summary_critique if candidate else None) or row.professional_summary or None,
|
||||||
|
"source": source,
|
||||||
|
"scored_candidate_id": str(candidate.id) if candidate else None,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
from datetime import date, time
|
from datetime import date, time
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
@ -11,23 +14,28 @@ from fastapi import HTTPException
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel, model_validator
|
from pydantic import BaseModel, model_validator
|
||||||
from inbox.models import Inbox_Messages
|
from inbox.models import AtsResults,Inbox_Messages
|
||||||
from job.assignment.views import Assignment
|
from job.assignment.views import Assignment
|
||||||
|
from job.candidate.models import Candidates
|
||||||
from job.job_post.enums import RequisitionStatus
|
from job.job_post.enums import RequisitionStatus
|
||||||
from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform
|
from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform
|
||||||
from role.models import EnumRoles
|
from role.models import EnumRoles
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
from job.job_post.plugins import (
|
from job.job_post.plugins import (
|
||||||
BufferError,
|
BufferError,
|
||||||
|
job_people_of,
|
||||||
create_buffer_post,
|
create_buffer_post,
|
||||||
list_buffer_channels,
|
list_buffer_channels,
|
||||||
local_status,
|
local_status,
|
||||||
normalize_platform,
|
normalize_platform,
|
||||||
|
optional_skill_hits,
|
||||||
parse_buffer_datetime,
|
parse_buffer_datetime,
|
||||||
render_job_post,
|
render_job_post,
|
||||||
resolve_channel,
|
resolve_channel,
|
||||||
|
suggested_source,
|
||||||
|
suggested_summary,
|
||||||
)
|
)
|
||||||
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history
|
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history, serialize_suggested_candidate
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
logger=logging.getLogger("job.job_post")
|
logger=logging.getLogger("job.job_post")
|
||||||
|
|
@ -41,28 +49,20 @@ MAX_JOB_IMAGE_BYTES=5*1024*1024
|
||||||
|
|
||||||
|
|
||||||
def _payload_recruiter_ids(payload):
|
def _payload_recruiter_ids(payload):
|
||||||
"""Prefer current_recruiter_ids; fall back to current_recruiter_id. None = omitted."""
|
"""None = the client did not send recruiters, so leave them alone.
|
||||||
has_list="current_recruiter_ids" in payload and payload.get("current_recruiter_ids") is not None
|
[] = the client sent an empty list, so remove every recruiter.
|
||||||
has_one="current_recruiter_id" in payload
|
|
||||||
if has_list:
|
|
||||||
raw=payload.get("current_recruiter_ids") or []
|
|
||||||
if not isinstance(raw,(list,tuple)):
|
|
||||||
raw=[raw]
|
|
||||||
ids=list(raw)
|
|
||||||
if not ids and has_one and payload.get("current_recruiter_id") not in (None,""):
|
|
||||||
ids=[payload.get("current_recruiter_id")]
|
|
||||||
return ids
|
|
||||||
if has_one:
|
|
||||||
raw=payload.get("current_recruiter_id")
|
|
||||||
return [] if raw in (None,"") else [raw]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
Checks for the key rather than truthiness: an empty list is falsy, and
|
||||||
|
reading it as "not sent" made the last recruiter impossible to remove.
|
||||||
|
"""
|
||||||
|
if "current_recruiter_ids" not in payload:
|
||||||
|
return None
|
||||||
|
return payload.get("current_recruiter_ids") or []
|
||||||
|
|
||||||
def _recruiter_fields(users):
|
def _recruiter_fields(users):
|
||||||
ids=[str(u.id) for u in users]
|
ids=[str(u.id) for u in users]
|
||||||
return {
|
return {
|
||||||
"current_recruiter_ids": ids,
|
"current_recruiter_ids": ids,
|
||||||
"current_recruiter_id": users[0].id if users else None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -83,6 +83,7 @@ class JobPostCreate(BaseModel):
|
||||||
location: str | None = None
|
location: str | None = None
|
||||||
employment_type: str | None = None
|
employment_type: str | None = None
|
||||||
department: str | None = None
|
department: str | None = None
|
||||||
|
department_id: UUID | None = None
|
||||||
vacancies: int = 1
|
vacancies: int = 1
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
platform: str | None = None
|
platform: str | None = None
|
||||||
|
|
@ -92,7 +93,6 @@ class JobPostCreate(BaseModel):
|
||||||
scheduler_date: date | None = None
|
scheduler_date: date | None = None
|
||||||
due_at: str | None = None
|
due_at: str | None = None
|
||||||
hiring_manager_id: UUID | None = None
|
hiring_manager_id: UUID | None = None
|
||||||
current_recruiter_id: UUID | None = None
|
|
||||||
current_recruiter_ids: list[UUID] | None = None
|
current_recruiter_ids: list[UUID] | None = None
|
||||||
requisition_id: UUID | None = None
|
requisition_id: UUID | None = None
|
||||||
|
|
||||||
|
|
@ -181,6 +181,7 @@ class JobPost:
|
||||||
"salary":payload.get("salary") or "Anonymous",
|
"salary":payload.get("salary") or "Anonymous",
|
||||||
# department is NOT NULL with a server_default of "" — pass "", never None.
|
# department is NOT NULL with a server_default of "" — pass "", never None.
|
||||||
"department":payload.get("department") or "",
|
"department":payload.get("department") or "",
|
||||||
|
"department_id":None,
|
||||||
"vacancies":payload.get("vacancies") or 1,
|
"vacancies":payload.get("vacancies") or 1,
|
||||||
"description":payload.get("description"),
|
"description":payload.get("description"),
|
||||||
"post_text":text,
|
"post_text":text,
|
||||||
|
|
@ -191,6 +192,10 @@ class JobPost:
|
||||||
# Only set platform when it is actually known: passing None would override the
|
# Only set platform when it is actually known: passing None would override the
|
||||||
# column default and break the NOT NULL constraint. Buffer's channelService
|
# column default and break the NOT NULL constraint. Buffer's channelService
|
||||||
# replaces this with the authoritative value once the post is created.
|
# 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)
|
known_platform=service or normalize_platform(payload.get("platform"),aliases)
|
||||||
if known_platform:
|
if known_platform:
|
||||||
fields["platform"]=known_platform
|
fields["platform"]=known_platform
|
||||||
|
|
@ -370,12 +375,12 @@ class JobPost:
|
||||||
async def fetch_requisition_statuses(self):
|
async def fetch_requisition_statuses(self):
|
||||||
return RequisitionStatus.as_list()
|
return RequisitionStatus.as_list()
|
||||||
|
|
||||||
async def fetch_status_history(self,job_post_id):
|
async def fetch_status_history(self,job_post_id,search=None,top=None,limit=None):
|
||||||
job=await JobPosts.get_job_post_by_id(self.session,job_post_id)
|
job=await JobPosts.get_job_post_by_id(self.session,job_post_id)
|
||||||
if not job or job.is_deleted:
|
if not job or job.is_deleted:
|
||||||
raise HTTPException(status_code=404,detail="Job post not found")
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id)
|
rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id,search,top,limit)
|
||||||
names=await Users.names_by_ids(self.session,[r.changed_by for r in rows])
|
names=await Users.names_by_ids(self.session,[r.changed_by for r in rows],search,top,limit)
|
||||||
return [
|
return [
|
||||||
serialize_status_history(r,changed_by_name=names.get(str(r.changed_by)))
|
serialize_status_history(r,changed_by_name=names.get(str(r.changed_by)))
|
||||||
for r in rows
|
for r in rows
|
||||||
|
|
@ -398,32 +403,88 @@ class JobPost:
|
||||||
employment_type=employment_type,hiring_manager_id=hm_uid,
|
employment_type=employment_type,hiring_manager_id=hm_uid,
|
||||||
restrict_ids=restrict,
|
restrict_ids=restrict,
|
||||||
)
|
)
|
||||||
names=await Users.names_by_ids(
|
people=await Users.job_people(
|
||||||
self.session,
|
self.session,
|
||||||
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)]+[r.hiring_manager_id for r in rows],
|
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)],
|
||||||
|
[r.hiring_manager_id for r in rows],
|
||||||
)
|
)
|
||||||
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
|
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
|
||||||
return [
|
return [
|
||||||
serialize_job_row(
|
serialize_job_row(
|
||||||
r,
|
r,
|
||||||
names=names,
|
people=job_people_of(r,people),
|
||||||
hiring_manager_name=names.get(str(r.hiring_manager_id)),
|
|
||||||
applicant_count=counts.get(str(r.id),0),
|
applicant_count=counts.get(str(r.id),0),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
],total
|
],total
|
||||||
|
|
||||||
async def _job_row(self,row):
|
async def fetch_job_profile(self,job_post_id,current_user=None,search=None,top=None,limit=None,skip=None):
|
||||||
names=await Users.names_by_ids(
|
"""Job profile page: the requisition row, its suggested candidates and the
|
||||||
|
Suggested / Top Match header stats — one round trip.
|
||||||
|
|
||||||
|
Suggested = newest ats_results row per person for this job. Profile fields
|
||||||
|
and keywords come from that score's candidates row; a user- or form-identity
|
||||||
|
score has none, so it borrows the newest completed row for the same email."""
|
||||||
|
uid=JobPosts._as_uuid(job_post_id)
|
||||||
|
|
||||||
|
if uid is None:
|
||||||
|
raise HTTPException(status_code=422,detail="job_post_id must be a UUID")
|
||||||
|
|
||||||
|
# it's a system admin job profile page, so we don't need to restrict the ids
|
||||||
|
restrict=await self._restrict_ids_for_requisition_scope(current_user)
|
||||||
|
|
||||||
|
if restrict is not None and str(uid) not in {str(i) for i in restrict}:
|
||||||
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
|
|
||||||
|
job=await JobPosts.get_job_post_by_id(self.session,uid)
|
||||||
|
if not job or job.is_deleted:
|
||||||
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
|
|
||||||
|
people=await Users.job_people(
|
||||||
self.session,
|
self.session,
|
||||||
JobPosts.recruiter_ids_of(row)+[row.hiring_manager_id],
|
JobPosts.recruiter_ids_of(job),
|
||||||
|
[job.hiring_manager_id],
|
||||||
)
|
)
|
||||||
return serialize_job_row(
|
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[job.id])
|
||||||
row,
|
job_payload=serialize_job_row(
|
||||||
names=names,
|
job,
|
||||||
hiring_manager_name=names.get(str(row.hiring_manager_id)),
|
people=people,
|
||||||
|
applicant_count=counts.get(str(job.id),0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
rows=await AtsResults.latest_per_candidate_for_job(self.session,job.id,search,top,limit,skip)
|
||||||
|
total=await AtsResults.count_suggested_for_job(self.session,job.id,search)
|
||||||
|
emails=[user_email or form_email for row,_,user_email,_,form_email,candidate in rows if candidate is None]
|
||||||
|
by_email=await Candidates.latest_completed_for_job_by_emails(self.session,job.id,emails)
|
||||||
|
candidates=[]
|
||||||
|
for row,user_name,user_email,form_name,form_email,candidate in rows:
|
||||||
|
email=user_email or form_email
|
||||||
|
scored=candidate or by_email.get((email or "").strip().lower())
|
||||||
|
candidates.append(serialize_suggested_candidate(
|
||||||
|
row,
|
||||||
|
name=user_name or form_name,
|
||||||
|
email=email,
|
||||||
|
candidate=scored,
|
||||||
|
source=suggested_source(row.inbox_id,row.form_data_id,scored.source if scored else None),
|
||||||
|
optional_matched=optional_skill_hits(job.optional_skills,scored.matched_keywords if scored else []),
|
||||||
|
))
|
||||||
|
return {"job":job_payload,**suggested_summary(candidates),"total":total,"candidates":candidates}
|
||||||
|
|
||||||
|
async def _job_row(self,row):
|
||||||
|
people=await Users.job_people(
|
||||||
|
self.session,
|
||||||
|
JobPosts.recruiter_ids_of(row),
|
||||||
|
[row.hiring_manager_id],
|
||||||
|
)
|
||||||
|
return serialize_job_row(row,people=job_people_of(row,people))
|
||||||
|
|
||||||
|
async def _require_department(self,department_id):
|
||||||
|
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):
|
async def update_job(self,job_post_id,payload,current_user):
|
||||||
if not current_user:
|
if not current_user:
|
||||||
raise HTTPException(status_code=401,detail="Not authenticated")
|
raise HTTPException(status_code=401,detail="Not authenticated")
|
||||||
|
|
@ -444,6 +505,16 @@ class JobPost:
|
||||||
fields["salary"]=str(high)
|
fields["salary"]=str(high)
|
||||||
if "department" in fields and fields["department"] is None:
|
if "department" in fields and fields["department"] is None:
|
||||||
fields["department"]=""
|
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)
|
assignment=Assignment(self.session)
|
||||||
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from talent.app import router as talent_router
|
||||||
from candidate_forms.app import router as candidate_forms_router
|
from candidate_forms.app import router as candidate_forms_router
|
||||||
from g_sheet.app import router as g_sheet_router
|
from g_sheet.app import router as g_sheet_router
|
||||||
from s3.app import router as s3_router
|
from s3.app import router as s3_router
|
||||||
|
from department.app import router as department_router
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
|
||||||
logger=logging.getLogger("main")
|
logger=logging.getLogger("main")
|
||||||
|
|
@ -136,3 +137,4 @@ app.include_router(talent_router)
|
||||||
app.include_router(candidate_forms_router)
|
app.include_router(candidate_forms_router)
|
||||||
app.include_router(g_sheet_router)
|
app.include_router(g_sheet_router)
|
||||||
app.include_router(s3_router)
|
app.include_router(s3_router)
|
||||||
|
app.include_router(department_router)
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
@ -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 $$;
|
||||||
|
|
@ -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));
|
||||||
|
|
@ -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 $$;
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
-- rbac_ingest.sql
|
||||||
|
-- One-shot ingestion of the RBAC tables: roles, permissions (bundles) and
|
||||||
|
-- permission_tags. Produces the same end state as manual migrations
|
||||||
|
-- 001, 004, 005, 007, 008, 019, 024, 026, 028 and 038 combined, so a fresh
|
||||||
|
-- database can be seeded in one pass.
|
||||||
|
--
|
||||||
|
-- Deliberately NOT under migrations/manual/ — run_manual_sql() only globs that
|
||||||
|
-- folder, so this file never auto-applies at startup. Run it by hand:
|
||||||
|
-- psql "$DATABASE_URL" -f migrations/seed/rbac_ingest.sql
|
||||||
|
--
|
||||||
|
-- Idempotent: every insert is ON CONFLICT DO NOTHING and bundle ids are only
|
||||||
|
-- appended to a role when missing, so re-running it is a no-op.
|
||||||
|
-- Users must log in again afterwards — the frontend caches /users/me permissions.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 1. permission_tags — every module x action (17 x 8 = 136), matches
|
||||||
|
-- users/permissions.py PermissionModule / PermissionAction
|
||||||
|
-- =============================================================================
|
||||||
|
INSERT INTO app.permission_tags
|
||||||
|
(tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
|
||||||
|
SELECT
|
||||||
|
m.module || '.' || a.action,
|
||||||
|
m.module,
|
||||||
|
a.action,
|
||||||
|
NULL,
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
FROM unnest(ARRAY[
|
||||||
|
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews',
|
||||||
|
'assessments', 'offers', 'reports', 'analytics', 'job_board', 'settings',
|
||||||
|
'rbac_users', 'tasks', 'talent', 'requisitions', 'department'
|
||||||
|
]) WITH ORDINALITY AS m(module, m_ord)
|
||||||
|
CROSS JOIN unnest(ARRAY[
|
||||||
|
'view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure'
|
||||||
|
]) WITH ORDINALITY AS a(action, a_ord)
|
||||||
|
ORDER BY m.m_ord, a.a_ord
|
||||||
|
ON CONFLICT (tag_name) DO NOTHING;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 2. roles — explicit ids: the app hardcodes candidate = 8 and
|
||||||
|
-- hiring_manager = 4 (inbox/models.py, users/models.py)
|
||||||
|
-- =============================================================================
|
||||||
|
INSERT INTO app.roles
|
||||||
|
(id, role_name, description, permissions, is_system, created_at, updated_at, is_active, is_deleted)
|
||||||
|
VALUES
|
||||||
|
(1, 'system_administrator', 'Full system access', '[]'::jsonb, true, NOW(), NOW(), true, false),
|
||||||
|
(2, 'hr_administrator', 'HR administration', '[]'::jsonb, true, NOW(), NOW(), false, true),
|
||||||
|
(3, 'recruiter', 'Recruiting staff', '[]'::jsonb, true, NOW(), NOW(), true, false),
|
||||||
|
(4, 'hiring_manager', 'Hiring manager for own requisitions', '[]'::jsonb, true, NOW(), NOW(), true, false),
|
||||||
|
(5, 'department_head', 'Head of a department', '[]'::jsonb, true, NOW(), NOW(), true, false),
|
||||||
|
(6, 'interviewer', 'Interview panel member', '[]'::jsonb, true, NOW(), NOW(), false, true),
|
||||||
|
(7, 'ceo', 'Chief executive', '[]'::jsonb, true, NOW(), NOW(), false, true),
|
||||||
|
(8, 'candidate', 'Applicant account', '[]'::jsonb, true, NOW(), NOW(), true, false)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Explicit ids bypass the sequence; move it past them so UI-created roles don't collide.
|
||||||
|
SELECT setval(
|
||||||
|
pg_get_serial_sequence('app.roles', 'id'),
|
||||||
|
GREATEST((SELECT MAX(id) FROM app.roles), 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 3. permissions — named bundles; each resolves to permission_tags ids by
|
||||||
|
-- module list and/or explicit tag names
|
||||||
|
-- =============================================================================
|
||||||
|
INSERT INTO app.permissions
|
||||||
|
(name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||||
|
SELECT
|
||||||
|
b.name,
|
||||||
|
b.description,
|
||||||
|
(
|
||||||
|
SELECT COALESCE(jsonb_agg(t.id ORDER BY t.id), '[]'::jsonb)
|
||||||
|
FROM app.permission_tags t
|
||||||
|
WHERE t.is_deleted = false
|
||||||
|
AND (t.module = ANY(b.modules) OR t.tag_name = ANY(b.tags))
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
FROM (VALUES
|
||||||
|
('all_access',
|
||||||
|
'Every permission in the original 13 modules',
|
||||||
|
ARRAY['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
|
||||||
|
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users']::text[],
|
||||||
|
ARRAY[]::text[]),
|
||||||
|
('analytics_dashboard',
|
||||||
|
'Dashboard KPI tiles, analytics charts, offers, and interview list',
|
||||||
|
ARRAY['dashboard', 'analytics', 'offers']::text[],
|
||||||
|
ARRAY['interviews.view']::text[]),
|
||||||
|
('tasks_management',
|
||||||
|
'Recruiting task list: view, create, complete and manage tasks',
|
||||||
|
ARRAY['tasks']::text[],
|
||||||
|
ARRAY[]::text[]),
|
||||||
|
('tasks_viewer',
|
||||||
|
'Recruiting task list: read-only access',
|
||||||
|
ARRAY[]::text[],
|
||||||
|
ARRAY['tasks.view', 'tasks.export']::text[]),
|
||||||
|
('talent_sourcing',
|
||||||
|
'LinkedIn talent sourcing: run Apify searches and view sourced profiles',
|
||||||
|
ARRAY['talent']::text[],
|
||||||
|
ARRAY[]::text[]),
|
||||||
|
('hiring_forms',
|
||||||
|
'Fill and amend candidate hiring forms (requisition, interview analysis, cultural fit)',
|
||||||
|
ARRAY[]::text[],
|
||||||
|
ARRAY['interviews.create', 'interviews.edit', 'interviews.delete']::text[]),
|
||||||
|
('requisitions_management',
|
||||||
|
'Employee requisition forms: view, create, edit and manage requisitions',
|
||||||
|
ARRAY['requisitions']::text[],
|
||||||
|
ARRAY[]::text[]),
|
||||||
|
('manager_candidates',
|
||||||
|
'Hiring manager: list candidates on own requisition jobs, view profiles, write notes',
|
||||||
|
ARRAY[]::text[],
|
||||||
|
ARRAY['candidates.view', 'candidates.create', 'candidates.edit']::text[]),
|
||||||
|
('requisitions_self',
|
||||||
|
'Own employee requisition forms: view, create, edit (not org-wide manage)',
|
||||||
|
ARRAY[]::text[],
|
||||||
|
ARRAY['requisitions.view', 'requisitions.create', 'requisitions.edit']::text[]),
|
||||||
|
('interviews_tab',
|
||||||
|
'Interviews and Calendar tabs: list, schedule, reschedule',
|
||||||
|
ARRAY[]::text[],
|
||||||
|
ARRAY['interviews.view', 'interviews.create', 'interviews.edit']::text[]),
|
||||||
|
('department_management',
|
||||||
|
'Departments: view, create, edit and manage departments',
|
||||||
|
ARRAY['department']::text[],
|
||||||
|
ARRAY[]::text[])
|
||||||
|
) AS b(name, description, modules, tags)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 4. roles.permissions — attach bundle ids (append only what is missing).
|
||||||
|
-- requisitions_self and interviews_tab are for custom roles, so unattached.
|
||||||
|
-- =============================================================================
|
||||||
|
WITH role_bundles(role_name, bundle_name) AS (
|
||||||
|
VALUES
|
||||||
|
('system_administrator', 'all_access'),
|
||||||
|
('system_administrator', 'analytics_dashboard'),
|
||||||
|
('system_administrator', 'tasks_management'),
|
||||||
|
('system_administrator', 'talent_sourcing'),
|
||||||
|
('system_administrator', 'hiring_forms'),
|
||||||
|
('system_administrator', 'requisitions_management'),
|
||||||
|
('system_administrator', 'department_management'),
|
||||||
|
|
||||||
|
('hr_administrator', 'analytics_dashboard'),
|
||||||
|
('hr_administrator', 'tasks_management'),
|
||||||
|
('hr_administrator', 'talent_sourcing'),
|
||||||
|
('hr_administrator', 'hiring_forms'),
|
||||||
|
('hr_administrator', 'requisitions_management'),
|
||||||
|
('hr_administrator', 'department_management'),
|
||||||
|
|
||||||
|
('recruiter', 'analytics_dashboard'),
|
||||||
|
('recruiter', 'tasks_management'),
|
||||||
|
('recruiter', 'talent_sourcing'),
|
||||||
|
('recruiter', 'hiring_forms'),
|
||||||
|
('recruiter', 'requisitions_management'),
|
||||||
|
|
||||||
|
('hiring_manager', 'analytics_dashboard'),
|
||||||
|
('hiring_manager', 'tasks_viewer'),
|
||||||
|
('hiring_manager', 'talent_sourcing'),
|
||||||
|
('hiring_manager', 'hiring_forms'),
|
||||||
|
('hiring_manager', 'requisitions_management'),
|
||||||
|
('hiring_manager', 'manager_candidates'),
|
||||||
|
|
||||||
|
('department_head', 'analytics_dashboard'),
|
||||||
|
('department_head', 'tasks_viewer'),
|
||||||
|
('department_head', 'talent_sourcing'),
|
||||||
|
('department_head', 'hiring_forms'),
|
||||||
|
('department_head', 'requisitions_management'),
|
||||||
|
|
||||||
|
('ceo', 'analytics_dashboard'),
|
||||||
|
('ceo', 'tasks_viewer'),
|
||||||
|
('ceo', 'talent_sourcing'),
|
||||||
|
('ceo', 'hiring_forms'),
|
||||||
|
('ceo', 'requisitions_management')
|
||||||
|
),
|
||||||
|
wanted AS (
|
||||||
|
SELECT rb.role_name, p.id AS permission_id
|
||||||
|
FROM role_bundles rb
|
||||||
|
JOIN app.permissions p ON p.name = rb.bundle_name AND p.is_deleted = false
|
||||||
|
)
|
||||||
|
UPDATE app.roles r
|
||||||
|
SET permissions = (
|
||||||
|
SELECT COALESCE(jsonb_agg(ids.id ORDER BY ids.id), '[]'::jsonb)
|
||||||
|
FROM (
|
||||||
|
SELECT value::int AS id
|
||||||
|
FROM jsonb_array_elements_text(COALESCE(r.permissions, '[]'::jsonb))
|
||||||
|
UNION
|
||||||
|
SELECT w.permission_id FROM wanted w WHERE w.role_name = r.role_name
|
||||||
|
) ids
|
||||||
|
),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM wanted w
|
||||||
|
WHERE w.role_name = r.role_name
|
||||||
|
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(w.permission_id))
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- 5. Verify: role -> bundles -> resolved tag count
|
||||||
|
-- =============================================================================
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.role_name,
|
||||||
|
r.is_deleted,
|
||||||
|
string_agg(DISTINCT p.name, ', ' ORDER BY p.name) AS bundles,
|
||||||
|
COUNT(DISTINCT t.id) AS tag_count
|
||||||
|
FROM app.roles r
|
||||||
|
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(r.permissions, '[]'::jsonb)) rp(pid) ON true
|
||||||
|
LEFT JOIN app.permissions p ON p.id = rp.pid::int AND p.is_deleted = false
|
||||||
|
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(p.permission_tags, '[]'::jsonb)) pt(tid) ON true
|
||||||
|
LEFT JOIN app.permission_tags t ON t.id = pt.tid::int AND t.is_deleted = false
|
||||||
|
GROUP BY r.id, r.role_name, r.is_deleted
|
||||||
|
ORDER BY r.id;
|
||||||
|
|
@ -208,17 +208,10 @@ async def system_admin_ids(session):
|
||||||
|
|
||||||
async def job_recruiter_ids(session, job):
|
async def job_recruiter_ids(session, job):
|
||||||
"""Recruiters currently linked to the job post.
|
"""Recruiters currently linked to the job post.
|
||||||
|
|
||||||
Uses the live pointer (current_recruiter_id), the JSON list
|
|
||||||
(current_recruiter_ids), and open job_assignments rows with
|
|
||||||
assignment_role=primary_recruiter.
|
|
||||||
"""
|
"""
|
||||||
ids = set()
|
ids = set()
|
||||||
if job is None:
|
if job is None:
|
||||||
return ids
|
return ids
|
||||||
uid = _as_uuid(getattr(job, "current_recruiter_id", None))
|
|
||||||
if uid is not None:
|
|
||||||
ids.add(uid)
|
|
||||||
for raw in getattr(job, "current_recruiter_ids", None) or []:
|
for raw in getattr(job, "current_recruiter_ids", None) or []:
|
||||||
extra = _as_uuid(raw)
|
extra = _as_uuid(raw)
|
||||||
if extra is not None:
|
if extra is not None:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
"""RBAC declared in code: system roles, permission bundles, and the SQL that syncs them.
|
||||||
|
|
||||||
|
`PermissionTag` (users/permissions.py) is the tag vocabulary. `SYSTEM_ROLES` and
|
||||||
|
`RBAC_BUNDLES` below declare the rest. Adding a module means adding its enum values
|
||||||
|
and a bundle entry here — no hand-written migrations/manual/*.sql.
|
||||||
|
|
||||||
|
`build_rbac_sql` turns the difference between this code and a database into
|
||||||
|
idempotent SQL. Admins curate roles in Access Control (a role's matrix replaces its
|
||||||
|
bundle list), so every code-declared grant — a tag inside a bundle, a bundle on a
|
||||||
|
role — is applied once per database and recorded in a ledger. A grant an admin later
|
||||||
|
removes stays removed; only grants the ledger has never seen are applied.
|
||||||
|
|
||||||
|
A database that already has roles but no ledger predates the sync. Its grants were
|
||||||
|
applied by the old manual migrations and may since have been curated, so the first
|
||||||
|
sync only records them. Bundles that do not exist yet are still created and filled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from role.models import EnumRoles
|
||||||
|
from users.permissions import PermissionModule, PermissionTag
|
||||||
|
|
||||||
|
RBAC_LEDGER_TABLE = "rbac_sync_ledger"
|
||||||
|
|
||||||
|
|
||||||
|
class SystemRole(NamedTuple):
|
||||||
|
id: int # pinned: the app hardcodes candidate = 8 and hiring_manager = 4
|
||||||
|
name: EnumRoles
|
||||||
|
description: str
|
||||||
|
retired: bool = False # soft-deleted by 026; created retired on a fresh database
|
||||||
|
|
||||||
|
|
||||||
|
class Bundle(NamedTuple):
|
||||||
|
description: str
|
||||||
|
modules: tuple[PermissionModule, ...] = ()
|
||||||
|
tags: tuple[PermissionTag, ...] = ()
|
||||||
|
roles: tuple[EnumRoles, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_ROLES: tuple[SystemRole, ...] = (
|
||||||
|
SystemRole(1, EnumRoles.SYSTEM_ADMINISTRATOR, "Full system access"),
|
||||||
|
SystemRole(2, EnumRoles.HR_ADMINISTRATOR, "HR administration", retired=True),
|
||||||
|
SystemRole(3, EnumRoles.RECRUITER, "Recruiting staff"),
|
||||||
|
SystemRole(4, EnumRoles.HIRING_MANAGER, "Hiring manager for own requisitions"),
|
||||||
|
SystemRole(5, EnumRoles.DEPARTMENT_HEAD, "Head of a department"),
|
||||||
|
SystemRole(6, EnumRoles.INTERVIEWER, "Interview panel member", retired=True),
|
||||||
|
SystemRole(7, EnumRoles.CEO, "Chief executive", retired=True),
|
||||||
|
SystemRole(8, EnumRoles.CANDIDATE, "Applicant account"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_R = EnumRoles
|
||||||
|
_T = PermissionTag
|
||||||
|
_M = PermissionModule
|
||||||
|
_STAFF = (
|
||||||
|
_R.SYSTEM_ADMINISTRATOR,
|
||||||
|
_R.HR_ADMINISTRATOR,
|
||||||
|
_R.RECRUITER,
|
||||||
|
_R.HIRING_MANAGER,
|
||||||
|
_R.DEPARTMENT_HEAD,
|
||||||
|
_R.CEO,
|
||||||
|
)
|
||||||
|
|
||||||
|
RBAC_BUNDLES: dict[str, Bundle] = {
|
||||||
|
"all_access": Bundle(
|
||||||
|
"Every permission in every module",
|
||||||
|
modules=tuple(PermissionModule),
|
||||||
|
roles=(_R.SYSTEM_ADMINISTRATOR,),
|
||||||
|
),
|
||||||
|
"analytics_dashboard": Bundle(
|
||||||
|
"Dashboard KPI tiles, analytics charts, offers, and interview list",
|
||||||
|
modules=(_M.DASHBOARD, _M.ANALYTICS, _M.OFFERS),
|
||||||
|
tags=(_T.INTERVIEWS_VIEW,),
|
||||||
|
roles=_STAFF,
|
||||||
|
),
|
||||||
|
"tasks_management": Bundle(
|
||||||
|
"Recruiting task list: view, create, complete and manage tasks",
|
||||||
|
modules=(_M.TASKS,),
|
||||||
|
roles=(_R.SYSTEM_ADMINISTRATOR, _R.HR_ADMINISTRATOR, _R.RECRUITER),
|
||||||
|
),
|
||||||
|
"tasks_viewer": Bundle(
|
||||||
|
"Recruiting task list: read-only access",
|
||||||
|
tags=(_T.TASKS_VIEW, _T.TASKS_EXPORT),
|
||||||
|
roles=(_R.HIRING_MANAGER, _R.DEPARTMENT_HEAD, _R.CEO),
|
||||||
|
),
|
||||||
|
"talent_sourcing": Bundle(
|
||||||
|
"LinkedIn talent sourcing: run Apify searches and view sourced profiles",
|
||||||
|
modules=(_M.TALENT,),
|
||||||
|
roles=_STAFF,
|
||||||
|
),
|
||||||
|
"hiring_forms": Bundle(
|
||||||
|
"Fill and amend candidate hiring forms (requisition, interview analysis, cultural fit)",
|
||||||
|
tags=(_T.INTERVIEWS_CREATE, _T.INTERVIEWS_EDIT, _T.INTERVIEWS_DELETE),
|
||||||
|
roles=_STAFF,
|
||||||
|
),
|
||||||
|
"requisitions_management": Bundle(
|
||||||
|
"Employee requisition forms: view, create, edit and manage requisitions",
|
||||||
|
modules=(_M.REQUISITIONS,),
|
||||||
|
roles=_STAFF,
|
||||||
|
),
|
||||||
|
"manager_candidates": Bundle(
|
||||||
|
"Hiring manager: list candidates on own requisition jobs, view profiles, write notes",
|
||||||
|
tags=(_T.CANDIDATES_VIEW, _T.CANDIDATES_CREATE, _T.CANDIDATES_EDIT),
|
||||||
|
roles=(_R.HIRING_MANAGER,),
|
||||||
|
),
|
||||||
|
# Unattached: admins tick these on custom roles in Access Control.
|
||||||
|
"requisitions_self": Bundle(
|
||||||
|
"Own employee requisition forms: view, create, edit (not org-wide manage)",
|
||||||
|
tags=(_T.REQUISITIONS_VIEW, _T.REQUISITIONS_CREATE, _T.REQUISITIONS_EDIT),
|
||||||
|
),
|
||||||
|
"interviews_tab": Bundle(
|
||||||
|
"Interviews and Calendar tabs: list, schedule, reschedule",
|
||||||
|
tags=(_T.INTERVIEWS_VIEW, _T.INTERVIEWS_CREATE, _T.INTERVIEWS_EDIT),
|
||||||
|
),
|
||||||
|
"department_management": Bundle(
|
||||||
|
"Departments: view, create, edit and manage departments",
|
||||||
|
modules=(_M.DEPARTMENT,),
|
||||||
|
roles=(_R.SYSTEM_ADMINISTRATOR, _R.HR_ADMINISTRATOR),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RbacState:
|
||||||
|
"""What the database already holds, read before planning."""
|
||||||
|
|
||||||
|
tags: frozenset[str]
|
||||||
|
roles: frozenset[str]
|
||||||
|
bundles: frozenset[str]
|
||||||
|
ledger: frozenset[tuple[str, str]] # (kind, key)
|
||||||
|
|
||||||
|
|
||||||
|
def bundle_tag_names(bundle: Bundle) -> list[str]:
|
||||||
|
"""The bundle's tags in vocabulary order: whole modules plus explicit tags."""
|
||||||
|
modules = {m.value for m in bundle.modules}
|
||||||
|
explicit = set(bundle.tags)
|
||||||
|
return [t.value for t in PermissionTag if t.value.split(".")[0] in modules or t in explicit]
|
||||||
|
|
||||||
|
|
||||||
|
def _lit(value: str) -> str:
|
||||||
|
return "'" + value.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _in(values: list[str]) -> str:
|
||||||
|
return "(" + ", ".join(_lit(v) for v in values) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(rows: list[str]) -> str:
|
||||||
|
return ",\n ".join(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def build_rbac_sql(state: RbacState, *, schema: str | None) -> list[str]:
|
||||||
|
"""Idempotent statements that bring the database up to the code. Empty when in sync."""
|
||||||
|
|
||||||
|
def table(name: str) -> str:
|
||||||
|
return f'"{schema}".{name}' if schema else name
|
||||||
|
|
||||||
|
tags_t = table("permission_tags")
|
||||||
|
roles_t = table("roles")
|
||||||
|
perms_t = table("permissions")
|
||||||
|
ledger_t = table(RBAC_LEDGER_TABLE)
|
||||||
|
adopt = not state.ledger and bool(state.roles)
|
||||||
|
sql: list[str] = []
|
||||||
|
recorded: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
missing_tags = [t.value for t in PermissionTag if t.value not in state.tags]
|
||||||
|
if missing_tags:
|
||||||
|
values = _rows([
|
||||||
|
f"({_lit(tag)}, {_lit(tag.split('.')[0])}, {_lit(tag.split('.')[1])}, "
|
||||||
|
"NOW(), NOW(), true, false)"
|
||||||
|
for tag in missing_tags
|
||||||
|
])
|
||||||
|
sql.append(f"""\
|
||||||
|
INSERT INTO {tags_t}
|
||||||
|
(tag_name, module, action, created_at, updated_at, is_active, is_deleted)
|
||||||
|
VALUES
|
||||||
|
{values}
|
||||||
|
ON CONFLICT (tag_name) DO NOTHING;""")
|
||||||
|
|
||||||
|
missing_roles = [r for r in SYSTEM_ROLES if r.name.value not in state.roles]
|
||||||
|
if missing_roles:
|
||||||
|
values = _rows([
|
||||||
|
f"({r.id}, {_lit(r.name.value)}, {_lit(r.description)}, '[]'::jsonb, true, "
|
||||||
|
f"NOW(), NOW(), {str(not r.retired).lower()}, {str(r.retired).lower()})"
|
||||||
|
for r in missing_roles
|
||||||
|
])
|
||||||
|
sql.append(f"""\
|
||||||
|
INSERT INTO {roles_t}
|
||||||
|
(id, role_name, description, permissions, is_system,
|
||||||
|
created_at, updated_at, is_active, is_deleted)
|
||||||
|
VALUES
|
||||||
|
{values}
|
||||||
|
ON CONFLICT DO NOTHING;""")
|
||||||
|
# Pinned ids bypass the sequence; move it past them so new roles don't collide.
|
||||||
|
sql.append(
|
||||||
|
f"SELECT setval(pg_get_serial_sequence('{roles_t}', 'id'), "
|
||||||
|
f"GREATEST((SELECT MAX(id) FROM {roles_t}), 1));"
|
||||||
|
)
|
||||||
|
|
||||||
|
for name, bundle in RBAC_BUNDLES.items():
|
||||||
|
created = name not in state.bundles
|
||||||
|
if created:
|
||||||
|
sql.append(f"""\
|
||||||
|
INSERT INTO {perms_t}
|
||||||
|
(name, description, permission_tags, is_system,
|
||||||
|
created_at, updated_at, is_active, is_deleted)
|
||||||
|
VALUES
|
||||||
|
({_lit(name)}, {_lit(bundle.description)}, '[]'::jsonb, true, NOW(), NOW(), true, false)
|
||||||
|
ON CONFLICT (name) DO NOTHING;""")
|
||||||
|
apply = created or not adopt
|
||||||
|
|
||||||
|
tags = [
|
||||||
|
t for t in bundle_tag_names(bundle)
|
||||||
|
if ("bundle_tag", f"{name}:{t}") not in state.ledger
|
||||||
|
]
|
||||||
|
if tags:
|
||||||
|
recorded += [("bundle_tag", f"{name}:{t}") for t in tags]
|
||||||
|
if apply:
|
||||||
|
sql.append(f"""\
|
||||||
|
UPDATE {perms_t} p
|
||||||
|
SET permission_tags = COALESCE(p.permission_tags, '[]'::jsonb) || (
|
||||||
|
SELECT COALESCE(jsonb_agg(t.id ORDER BY t.id), '[]'::jsonb)
|
||||||
|
FROM {tags_t} t
|
||||||
|
WHERE t.tag_name IN {_in(tags)}
|
||||||
|
AND NOT (COALESCE(p.permission_tags, '[]'::jsonb) @> jsonb_build_array(t.id))
|
||||||
|
),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE p.name = {_lit(name)};""")
|
||||||
|
|
||||||
|
roles = [
|
||||||
|
r.value for r in bundle.roles
|
||||||
|
if ("role_bundle", f"{r.value}:{name}") not in state.ledger
|
||||||
|
]
|
||||||
|
if roles:
|
||||||
|
recorded += [("role_bundle", f"{r}:{name}") for r in roles]
|
||||||
|
if apply:
|
||||||
|
sql.append(f"""\
|
||||||
|
UPDATE {roles_t} r
|
||||||
|
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM {perms_t} p
|
||||||
|
WHERE p.name = {_lit(name)}
|
||||||
|
AND r.role_name IN {_in(roles)}
|
||||||
|
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));""")
|
||||||
|
|
||||||
|
if not sql and not recorded:
|
||||||
|
return []
|
||||||
|
|
||||||
|
ledger_sql = [f"""\
|
||||||
|
CREATE TABLE IF NOT EXISTS {ledger_t} (
|
||||||
|
kind text NOT NULL,
|
||||||
|
key text NOT NULL,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (kind, key)
|
||||||
|
);"""]
|
||||||
|
if recorded:
|
||||||
|
values = _rows([f"({_lit(kind)}, {_lit(key)})" for kind, key in recorded])
|
||||||
|
ledger_sql.append(f"""\
|
||||||
|
INSERT INTO {ledger_t} (kind, key)
|
||||||
|
VALUES
|
||||||
|
{values}
|
||||||
|
ON CONFLICT DO NOTHING;""")
|
||||||
|
return ledger_sql + sql
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Unit tests for the job-profile helpers in job/job_post/plugins.py — pure functions only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from job.job_post import plugins
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- suggested_source
|
||||||
|
|
||||||
|
def test_inbox_score_is_email_sourced_even_with_a_candidates_row():
|
||||||
|
assert plugins.suggested_source(42, None, "upload") == "inbox"
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_score_is_form_sourced():
|
||||||
|
assert plugins.suggested_source(None, "f-1", None) == "form"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_score_uses_the_candidates_row_source():
|
||||||
|
assert plugins.suggested_source(None, None, "bank") == "bank"
|
||||||
|
assert plugins.suggested_source(None, None, None) == "upload"
|
||||||
|
assert plugins.suggested_source(None, None, " ") == "upload"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- optional_skill_hits
|
||||||
|
|
||||||
|
def test_exact_match_ignores_case_and_punctuation():
|
||||||
|
assert plugins.optional_skill_hits(["FMCG Background", "Arabic"], ["fmcg-background"]) == ["FMCG Background"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyword_inside_skill_counts_on_word_boundaries():
|
||||||
|
assert plugins.optional_skill_hits(["CRM (Salesforce)"], ["Salesforce"]) == ["CRM (Salesforce)"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_inside_keyword_counts():
|
||||||
|
assert plugins.optional_skill_hits(["Arabic"], ["Arabic language"]) == ["Arabic"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_words_do_not_count():
|
||||||
|
assert plugins.optional_skill_hits(["Java"], ["JavaScript"]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_hits_keep_job_order_and_skip_duplicates():
|
||||||
|
hits = plugins.optional_skill_hits(["Travel", "Arabic", "Arabic"], ["arabic", "travel"])
|
||||||
|
assert hits == ["Travel", "Arabic"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_optional_skills_or_keywords_is_empty():
|
||||||
|
assert plugins.optional_skill_hits([], ["Python"]) == []
|
||||||
|
assert plugins.optional_skill_hits(["Python"], None) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- suggested_summary
|
||||||
|
|
||||||
|
def test_summary_counts_bands_and_top_match():
|
||||||
|
candidates = [
|
||||||
|
{"band": "Strong Match", "match_score": 91},
|
||||||
|
{"band": "Strong Match", "match_score": 85},
|
||||||
|
{"band": "Potential Match", "match_score": 70},
|
||||||
|
{"band": "Weak Match", "match_score": 40},
|
||||||
|
{"band": None, "match_score": None},
|
||||||
|
]
|
||||||
|
summary = plugins.suggested_summary(candidates)
|
||||||
|
assert summary["suggested"] == 5
|
||||||
|
assert summary["top_match"] == 2
|
||||||
|
assert summary["top_score"] == 91
|
||||||
|
assert summary["bands"] == {"Strong Match": 2, "Potential Match": 1, "Weak Match": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_summary():
|
||||||
|
assert plugins.suggested_summary([]) == {
|
||||||
|
"suggested": 0,
|
||||||
|
"top_match": 0,
|
||||||
|
"top_score": None,
|
||||||
|
"bands": {"Strong Match": 0, "Potential Match": 0, "Weak Match": 0},
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select
|
||||||
|
|
||||||
from role.models import Roles
|
from role.models import EnumRoles, Roles
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module
|
if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module
|
||||||
|
|
@ -31,11 +31,6 @@ class Users(SQLModel, table=True):
|
||||||
role: Roles | None = Relationship(back_populates="users",
|
role: Roles | None = Relationship(back_populates="users",
|
||||||
sa_relationship_kwargs={"lazy": "selectin"}
|
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(
|
job_posts: List[JobPosts] = Relationship(
|
||||||
back_populates="user",
|
back_populates="user",
|
||||||
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
|
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()]
|
return [row[0] for row in result.all()]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
async def names_by_ids(cls, session: AsyncSession, user_ids,search=None,top=None,limit=None) -> dict[str, str]:
|
||||||
"""Resolve {user_id: name} in a single query.
|
"""Resolve {user_id: name} in a single query, whatever role those ids hold.
|
||||||
|
|
||||||
|
Shared by departments, offers, history, notifications and the job payloads,
|
||||||
|
so it stays role-agnostic: a read-time role filter cannot fix bad data, it
|
||||||
|
only makes names disappear. Role is enforced on write by require_role.
|
||||||
|
|
||||||
COLUMN select, not the Users entity: `select(cls)` would pull the five
|
COLUMN select, not the Users entity: `select(cls)` would pull the five
|
||||||
selectin relations (role, job_posts, inbox, feedback, notes) for a
|
selectin relations (role, job_posts, inbox, feedback, notes) for a
|
||||||
|
|
@ -169,11 +168,49 @@ class Users(SQLModel, table=True):
|
||||||
uids = {u for u in (user_ids or []) if u}
|
uids = {u for u in (user_ids or []) if u}
|
||||||
if not uids:
|
if not uids:
|
||||||
return {}
|
return {}
|
||||||
result = await session.execute(
|
statement =(
|
||||||
select(cls.id, cls.name).where(cls.id.in_(uids))
|
select(cls.id, cls.name).where(cls.id.in_(uids))
|
||||||
)
|
)
|
||||||
|
if search:
|
||||||
|
statement = statement.where(cls.name.ilike(f"%{search}%"))
|
||||||
|
if top:
|
||||||
|
statement = statement.limit(top)
|
||||||
|
if limit:
|
||||||
|
statement = statement.limit(limit)
|
||||||
|
result = await session.execute(statement)
|
||||||
return {str(uid): name for uid, name in result.all()}
|
return {str(uid): name for uid, name in result.all()}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def job_people(cls, session: AsyncSession, recruiter_ids, manager_ids=None) -> dict[str, dict[str, str]]:
|
||||||
|
"""{"recruiters": {id: name}, "hiring_manager": {id: name}} — the two job
|
||||||
|
ownership roles resolved apart, one query each.
|
||||||
|
|
||||||
|
They are different roles on a job post, so they never share a container.
|
||||||
|
The manager side is role-checked (hiring_manager, not deleted) because it
|
||||||
|
is a single stable owner; recruiters are validated on write. Both sides
|
||||||
|
take a list, so one call serves a whole page of jobs.
|
||||||
|
"""
|
||||||
|
data: dict[str, dict[str, str]] = {"recruiters": {}, "hiring_manager": {}}
|
||||||
|
|
||||||
|
rids = {u for u in (recruiter_ids or []) if u}
|
||||||
|
if rids:
|
||||||
|
result = await session.execute(select(cls.id, cls.name).where(cls.id.in_(rids)))
|
||||||
|
data["recruiters"] = {str(uid): name for uid, name in result.all()}
|
||||||
|
|
||||||
|
mids = {u for u in (manager_ids or []) if u}
|
||||||
|
if mids:
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls.id, cls.name)
|
||||||
|
.join(Roles, Roles.id == cls.role_id)
|
||||||
|
.where(
|
||||||
|
cls.id.in_(mids),
|
||||||
|
Roles.role_name == EnumRoles.HIRING_MANAGER.value,
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
data["hiring_manager"] = {str(uid): name for uid, name in result.all()}
|
||||||
|
return data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_ids(cls, session: AsyncSession, ids):
|
async def get_by_ids(cls, session: AsyncSession, ids):
|
||||||
"""Users with role selectin-loaded. UUID keys so callers can map by row.assignee_id."""
|
"""Users with role selectin-loaded. UUID keys so callers can map by row.assignee_id."""
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ class PermissionModule(str, Enum):
|
||||||
JOBS = "jobs"
|
JOBS = "jobs"
|
||||||
CANDIDATES = "candidates"
|
CANDIDATES = "candidates"
|
||||||
PIPELINE = "pipeline"
|
PIPELINE = "pipeline"
|
||||||
|
DEPARTMENT = "department"
|
||||||
INTERVIEWS = "interviews"
|
INTERVIEWS = "interviews"
|
||||||
ASSESSMENTS = "assessments"
|
ASSESSMENTS = "assessments"
|
||||||
OFFERS = "offers"
|
OFFERS = "offers"
|
||||||
|
|
@ -71,6 +72,16 @@ class PermissionTag(str, Enum):
|
||||||
DASHBOARD_EXPORT = "dashboard.export"
|
DASHBOARD_EXPORT = "dashboard.export"
|
||||||
DASHBOARD_MANAGE = "dashboard.manage"
|
DASHBOARD_MANAGE = "dashboard.manage"
|
||||||
DASHBOARD_CONFIGURE = "dashboard.configure"
|
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_VIEW = "inbox.view"
|
||||||
INBOX_CREATE = "inbox.create"
|
INBOX_CREATE = "inbox.create"
|
||||||
INBOX_EDIT = "inbox.edit"
|
INBOX_EDIT = "inbox.edit"
|
||||||
|
|
|
||||||
|
|
@ -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 ====="
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
/**
|
||||||
|
* Job profile page test — /job/:jobId rendered into jsdom against a mocked
|
||||||
|
* GET /jobs/profile/fetch.
|
||||||
|
*
|
||||||
|
* node job-profile.test.mjs
|
||||||
|
*
|
||||||
|
* Pins the design contract: employment type in the hero line, Suggested and
|
||||||
|
* Top Match stats from the one profile request, Optional Skills highlighted
|
||||||
|
* below Required Skills, and suggested-candidate cards ranked best → worst with
|
||||||
|
* matched optional skills highlighted on each card.
|
||||||
|
*/
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
import esbuild from 'esbuild'
|
||||||
|
import { JSDOM } from 'jsdom'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- environment
|
||||||
|
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||||
|
url: 'http://localhost:5173/',
|
||||||
|
pretendToBeVisual: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
globalThis.window = dom.window
|
||||||
|
globalThis.document = dom.window.document
|
||||||
|
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
|
||||||
|
globalThis.HTMLElement = dom.window.HTMLElement
|
||||||
|
globalThis.Element = dom.window.Element
|
||||||
|
globalThis.Node = dom.window.Node
|
||||||
|
globalThis.getComputedStyle = dom.window.getComputedStyle
|
||||||
|
globalThis.localStorage = dom.window.localStorage
|
||||||
|
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0)
|
||||||
|
globalThis.cancelAnimationFrame = clearTimeout
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||||
|
|
||||||
|
class RO { observe() {} unobserve() {} disconnect() {} }
|
||||||
|
class MO { observe() {} disconnect() {} takeRecords() { return [] } }
|
||||||
|
globalThis.ResizeObserver = RO
|
||||||
|
globalThis.MutationObserver = MO
|
||||||
|
dom.window.ResizeObserver = RO
|
||||||
|
dom.window.MutationObserver = MO
|
||||||
|
dom.window.matchMedia = () => ({
|
||||||
|
matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {},
|
||||||
|
})
|
||||||
|
dom.window.HTMLCanvasElement.prototype.getContext = () =>
|
||||||
|
new Proxy({}, { get: (_, k) => (k === 'canvas' ? {} : () => {}) })
|
||||||
|
|
||||||
|
const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
|
||||||
|
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent',
|
||||||
|
'requisitions']
|
||||||
|
const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure']
|
||||||
|
const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`))
|
||||||
|
|
||||||
|
dom.window.localStorage.setItem('tf-auth', JSON.stringify({
|
||||||
|
access_token: 'test', refresh_token: 'test', expires_in: 1800,
|
||||||
|
expires_at: Date.now() + 1800_000,
|
||||||
|
data: { id: 1, name: 'Test User', email: 't@example.com', role_name: 'system_administrator', permissions },
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- fixtures
|
||||||
|
const JOB_ID = '11111111-2222-3333-4444-555555555555'
|
||||||
|
|
||||||
|
function jobRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: JOB_ID,
|
||||||
|
title: 'Regional Sales Executive',
|
||||||
|
department: 'GEO',
|
||||||
|
location: 'Multi-region',
|
||||||
|
employment_type: 'Permanent',
|
||||||
|
vacancies: 3,
|
||||||
|
platform: 'linkedin',
|
||||||
|
requisition_status: 'open',
|
||||||
|
status: 'draft',
|
||||||
|
experience_min: 3,
|
||||||
|
experience_max: 5,
|
||||||
|
requirements: ['B2B Sales', 'Distributor Management'],
|
||||||
|
optional_skills: ['Arabic', 'FMCG Background', 'Regional Travel'],
|
||||||
|
description: 'Own the sales pipeline across the GEO region.',
|
||||||
|
current_recruiter_ids: [],
|
||||||
|
recruiter_names: ['Nida Khan'],
|
||||||
|
hiring_manager_name: 'Amara Osei',
|
||||||
|
applicant_count: 42,
|
||||||
|
created_by_name: 'Nida Khan',
|
||||||
|
created_at: '2026-08-12T09:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidate(overrides) {
|
||||||
|
return {
|
||||||
|
id: overrides.id,
|
||||||
|
user_id: null, candidate_id: null, form_data_id: null, inbox_id: null,
|
||||||
|
email: null, current_title: null, current_company: null, years_experience: null,
|
||||||
|
matched_keywords: [], missing_keywords: [], optional_matched: [],
|
||||||
|
summary: null, source: 'upload', scored_candidate_id: null,
|
||||||
|
created_at: '2026-09-01T10:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CANDIDATES = [
|
||||||
|
candidate({
|
||||||
|
id: 'a1', name: 'Sana Iqbal', match_score: 91, band: 'Strong Match', source: 'inbox',
|
||||||
|
current_title: 'Regional Sales Manager', current_company: 'Unilever', years_experience: 6,
|
||||||
|
matched_keywords: ['B2B Sales', 'Distributor Management'], optional_matched: ['Arabic'],
|
||||||
|
summary: 'Six years leading distributor relationships across GCC.', created_at: '2026-09-01T10:00:00Z',
|
||||||
|
}),
|
||||||
|
candidate({
|
||||||
|
id: 'a2', name: 'Ayesha Noor', match_score: 78, band: 'Potential Match',
|
||||||
|
matched_keywords: ['B2B Sales'], missing_keywords: ['Distributor Management'],
|
||||||
|
created_at: '2026-09-05T10:00:00Z',
|
||||||
|
}),
|
||||||
|
candidate({
|
||||||
|
id: 'a3', name: 'Bilal Ahmed', match_score: 40, band: 'Weak Match', source: 'form',
|
||||||
|
created_at: '2026-09-03T10:00:00Z',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
let profilePayload = null
|
||||||
|
const REQUESTS = []
|
||||||
|
|
||||||
|
/** Stand-in for the API's search → offset → limit, so paging is exercised
|
||||||
|
against a server that really returns one page and a total. */
|
||||||
|
function serveProfile(url) {
|
||||||
|
const params = new URL(url, 'http://localhost').searchParams
|
||||||
|
const term = (params.get('search') || '').toLowerCase()
|
||||||
|
const top = Number(params.get('top') || 0)
|
||||||
|
const skip = Number(params.get('skip') || 0)
|
||||||
|
const matching = profilePayload.candidates.filter((c) => !term || String(c.name).toLowerCase().includes(term))
|
||||||
|
const page = top ? matching.slice(skip, skip + top) : matching.slice(skip)
|
||||||
|
return { data: { ...profilePayload, total: matching.length, candidates: page }, total: matching.length, status_code: 200 }
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.fetch = async (input) => {
|
||||||
|
const url = String(input?.url ?? input)
|
||||||
|
REQUESTS.push(url)
|
||||||
|
const body = url.includes('/jobs/profile/fetch')
|
||||||
|
? serveProfile(url)
|
||||||
|
: { data: [], status_code: 200 }
|
||||||
|
return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- bundle
|
||||||
|
const outDir = mkdtempSync(join(tmpdir(), 'tf-jobprofile-'))
|
||||||
|
const outFile = join(outDir, 'entry.mjs')
|
||||||
|
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: ['src/__smoke__/entry.jsx'],
|
||||||
|
outfile: outFile,
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node20',
|
||||||
|
jsx: 'automatic',
|
||||||
|
loader: { '.js': 'jsx', '.jsx': 'jsx' },
|
||||||
|
logLevel: 'error',
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': '"development"',
|
||||||
|
'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- run
|
||||||
|
const errors = []
|
||||||
|
const origError = console.error
|
||||||
|
console.error = (...args) => {
|
||||||
|
const msg = args.map((a) => (a instanceof Error ? a.stack : String(a))).join(' ')
|
||||||
|
if (msg.includes('React Router Future Flag')) return
|
||||||
|
errors.push(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = 0
|
||||||
|
function check(name, ok, detail = '') {
|
||||||
|
if (ok) console.log(`ok ${name}`)
|
||||||
|
else {
|
||||||
|
failed++
|
||||||
|
console.log(`FAIL ${name}${detail ? `\n ${detail}` : ''}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mod = await import(pathToFileURL(outFile).href)
|
||||||
|
mod.boot()
|
||||||
|
|
||||||
|
// ------------------------------------------------ full profile
|
||||||
|
profilePayload = {
|
||||||
|
job: jobRow(),
|
||||||
|
suggested: 3,
|
||||||
|
top_match: 1,
|
||||||
|
top_score: 91,
|
||||||
|
bands: { 'Strong Match': 1, 'Potential Match': 1, 'Weak Match': 1 },
|
||||||
|
candidates: CANDIDATES,
|
||||||
|
}
|
||||||
|
let container = dom.window.document.createElement('div')
|
||||||
|
dom.window.document.body.appendChild(container)
|
||||||
|
let m = await mod.mountRoute(`/job/${JOB_ID}`, container)
|
||||||
|
await m.settle(60)
|
||||||
|
|
||||||
|
const profileCalls = REQUESTS.filter((u) => u.includes('/jobs/profile/fetch'))
|
||||||
|
check('one profile request feeds the page', profileCalls.length === 1, `calls=${profileCalls.length}`)
|
||||||
|
check('profile request carries the job id', profileCalls[0]?.includes(`job_post_id=${JOB_ID}`), profileCalls[0])
|
||||||
|
|
||||||
|
const role = m.find('.job-hero .ph-role')?.textContent || ''
|
||||||
|
check('hero line shows department, location and employment type', role === 'GEO · Multi-region · Permanent', `role="${role}"`)
|
||||||
|
|
||||||
|
const stats = [...container.querySelectorAll('.hero-stat')].map((el) => ({
|
||||||
|
v: el.querySelector('.v')?.textContent, l: el.querySelector('.l')?.textContent,
|
||||||
|
}))
|
||||||
|
check('Suggested stat reads 3', stats.some((s) => s.l === 'Suggested' && s.v === '3'), JSON.stringify(stats))
|
||||||
|
check('Top Match stat counts the Strong Match band', stats.some((s) => s.l === 'Top Match' && s.v === '1'), JSON.stringify(stats))
|
||||||
|
check('hero tags show vacancies and applicants', m.text().includes('3 Vacancies') && m.text().includes('42 Applicants'))
|
||||||
|
|
||||||
|
const html = m.html()
|
||||||
|
const reqAt = html.indexOf('Required Skills')
|
||||||
|
const optAt = html.indexOf('Optional Skills')
|
||||||
|
check('Optional Skills section sits below Required Skills', reqAt > -1 && optAt > reqAt, `req=${reqAt} opt=${optAt}`)
|
||||||
|
const optionalTags = [...container.querySelectorAll('.tag.tag-optional')].map((el) => el.textContent.trim())
|
||||||
|
check('every optional skill is a highlighted tag', optionalTags.join('|') === 'Arabic|FMCG Background|Regional Travel', optionalTags.join('|'))
|
||||||
|
check('created line joins date and author', m.text().includes('by Nida Khan'))
|
||||||
|
|
||||||
|
// ------------------------------------------------ suggested tab
|
||||||
|
await m.click(m.findByText('[role="tab"]', 'Suggested Candidates'))
|
||||||
|
let cards = [...container.querySelectorAll('.cand-card')]
|
||||||
|
check('one card per suggested candidate', cards.length === 3, `cards=${cards.length}`)
|
||||||
|
const names = () => [...container.querySelectorAll('.cand-card .cand-name')].map((el) => el.textContent.trim())
|
||||||
|
check('default sort is best → worst', names().join('|') === 'Sana Iqbal|Ayesha Noor|Bilal Ahmed', names().join('|'))
|
||||||
|
const ranks = [...container.querySelectorAll('.cand-rank')].map((el) => el.textContent)
|
||||||
|
check('cards are ranked 1..n', ranks.join(',') === '1,2,3', ranks.join(','))
|
||||||
|
check('sub line counts and marks the sort', m.text().includes('3 candidates suggested') && m.text().includes('Sorted: Best → Worst'))
|
||||||
|
|
||||||
|
const first = container.querySelector('.cand-card')
|
||||||
|
const optChips = [...first.querySelectorAll('.cand-chip.opt')].map((el) => el.textContent.trim())
|
||||||
|
check('matched optional skill is highlighted on its card', optChips.join('|') === 'Arabic', optChips.join('|'))
|
||||||
|
check('matched and missing chips render', first.querySelectorAll('.cand-chip.ok').length === 2
|
||||||
|
&& container.querySelectorAll('.cand-card')[1].querySelectorAll('.cand-chip.miss').length === 1)
|
||||||
|
check('source label maps inbox → Email', first.textContent.includes('Email'))
|
||||||
|
check('foot shows years and company', first.textContent.includes('6 yrs · Unilever'))
|
||||||
|
|
||||||
|
const sortSelect = container.querySelector('#suggested-sort')
|
||||||
|
await m.selectOption(sortSelect, 'name')
|
||||||
|
check('A → Z sort orders by name and drops ranks',
|
||||||
|
names().join('|') === 'Ayesha Noor|Bilal Ahmed|Sana Iqbal' && !container.querySelector('.cand-rank'), names().join('|'))
|
||||||
|
await m.selectOption(sortSelect, 'recent')
|
||||||
|
check('Most Recent sort orders by score time', names().join('|') === 'Ayesha Noor|Bilal Ahmed|Sana Iqbal', names().join('|'))
|
||||||
|
|
||||||
|
await m.selectOption(container.querySelector('select[aria-label="Match band"]'), 'Weak Match')
|
||||||
|
check('band filter narrows to that band', names().join('|') === 'Bilal Ahmed', names().join('|'))
|
||||||
|
|
||||||
|
// ------------------------------------------------ search / top go to the API
|
||||||
|
const profileCallsNow = () => REQUESTS.filter((u) => u.includes('/jobs/profile/fetch'))
|
||||||
|
const lastParams = () => new URL(profileCallsNow().at(-1), 'http://localhost').searchParams
|
||||||
|
check('first request sends the default size as top=50', new URL(profileCallsNow()[0], 'http://localhost').searchParams.get('top') === '50')
|
||||||
|
check('first request sends no search', !new URL(profileCallsNow()[0], 'http://localhost').searchParams.has('search'))
|
||||||
|
check('first request sends no limit', !new URL(profileCallsNow()[0], 'http://localhost').searchParams.has('limit'))
|
||||||
|
|
||||||
|
const before = profileCallsNow().length
|
||||||
|
await m.type(container.querySelector('.toolbar-search input'), 'sana')
|
||||||
|
check('typing a search re-requests the profile', profileCallsNow().length > before)
|
||||||
|
check('search is sent as search=', lastParams().get('search') === 'sana', lastParams().get('search'))
|
||||||
|
check('search keeps the size', lastParams().get('top') === '50')
|
||||||
|
|
||||||
|
await m.selectOption(container.querySelector('.page-size-select'), '10')
|
||||||
|
check('Show changes top', lastParams().get('top') === '10', lastParams().get('top'))
|
||||||
|
check('Show keeps the search', lastParams().get('search') === 'sana')
|
||||||
|
|
||||||
|
await m.type(container.querySelector('.toolbar-search input'), ' ')
|
||||||
|
check('blank search is not sent', !lastParams().has('search'))
|
||||||
|
await m.unmount()
|
||||||
|
container.remove()
|
||||||
|
|
||||||
|
// ------------------------------------------------ page arrows
|
||||||
|
// 120 candidates: two full pages of 50 and a short third page of 20.
|
||||||
|
const PAGED_ID = '77777777-2222-3333-4444-555555555555'
|
||||||
|
profilePayload = {
|
||||||
|
job: jobRow({ id: PAGED_ID }),
|
||||||
|
suggested: 50, top_match: 0, top_score: 100,
|
||||||
|
bands: { 'Strong Match': 0, 'Potential Match': 0, 'Weak Match': 0 },
|
||||||
|
candidates: Array.from({ length: 120 }, (_, i) => candidate({
|
||||||
|
id: `p${i}`, name: `Candidate ${String(i).padStart(3, '0')}`, match_score: 100 - i, band: 'Weak Match',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
container = dom.window.document.createElement('div')
|
||||||
|
dom.window.document.body.appendChild(container)
|
||||||
|
m = await mod.mountRoute(`/job/${PAGED_ID}?tab=suggested`, container)
|
||||||
|
await m.settle(60)
|
||||||
|
|
||||||
|
const pageCards = () => container.querySelectorAll('.cand-card').length
|
||||||
|
const pageNames = () => [...container.querySelectorAll('.cand-name')].map((el) => el.textContent.trim())
|
||||||
|
const pageRanks = () => [...container.querySelectorAll('.cand-rank')].map((el) => el.textContent)
|
||||||
|
const lastCall = () => new URL(REQUESTS.filter((u) => u.includes('/jobs/profile/fetch')).at(-1), 'http://localhost').searchParams
|
||||||
|
|
||||||
|
check('pagination control renders under the grid', container.querySelectorAll('.pagination').length === 1)
|
||||||
|
check('first page holds 50 of 120', pageCards() === 50, `cards=${pageCards()}`)
|
||||||
|
check('page info reads 1–50 of 120', m.text().includes('1–50') && m.text().includes('of 120'))
|
||||||
|
check('first request asks for skip-less page 1', !lastCall().get('skip') || lastCall().get('skip') === '0')
|
||||||
|
check('Suggested stat shows the whole total, not the page', container.querySelector('.hero-stat .v')?.textContent === '120')
|
||||||
|
check('previous arrow is disabled on page 1', container.querySelector('.page-btn[aria-label="Previous page"]')?.disabled === true)
|
||||||
|
|
||||||
|
await m.click(container.querySelector('.page-btn[aria-label="Next page"]'))
|
||||||
|
check('next arrow requests skip=50', lastCall().get('skip') === '50', lastCall().get('skip'))
|
||||||
|
check('next arrow keeps top=50', lastCall().get('top') === '50')
|
||||||
|
check('page 2 starts where page 1 stopped', pageNames()[0] === 'Candidate 050', pageNames()[0])
|
||||||
|
check('ranks continue across pages', pageRanks()[0] === '51' && pageRanks().at(-1) === '100', pageRanks()[0])
|
||||||
|
|
||||||
|
await m.click(container.querySelector('.page-btn[aria-label="Last page"]'))
|
||||||
|
check('last page requests skip=100', lastCall().get('skip') === '100')
|
||||||
|
check('last page holds the remaining 20', pageCards() === 20, `cards=${pageCards()}`)
|
||||||
|
check('page info reads 101–120 of 120', m.text().includes('101–120'))
|
||||||
|
check('next arrow is disabled on the last page', container.querySelector('.page-btn[aria-label="Next page"]')?.disabled === true)
|
||||||
|
|
||||||
|
await m.click(container.querySelector('.page-btn[aria-label="Previous page"]'))
|
||||||
|
// Page 2 was fetched already, so React Query may serve it from cache without a
|
||||||
|
// new request — assert what is on screen, not the last URL.
|
||||||
|
check('previous arrow steps back to page 2', pageNames()[0] === 'Candidate 050' && m.text().includes('51–100'), pageNames()[0])
|
||||||
|
|
||||||
|
await m.type(container.querySelector('.toolbar-search input'), 'Candidate 11')
|
||||||
|
check('a new search goes back to page 1', !lastCall().get('skip') || lastCall().get('skip') === '0', lastCall().get('skip'))
|
||||||
|
check('search narrows the total', m.text().includes('of 10'), m.text().match(/of \d+/)?.[0])
|
||||||
|
await m.unmount()
|
||||||
|
container.remove()
|
||||||
|
|
||||||
|
// ------------------------------------------------ sparse job
|
||||||
|
// A different id: the query client is shared across mounts, so the first
|
||||||
|
// job's profile is still cached under its own key.
|
||||||
|
const SPARSE_ID = '99999999-2222-3333-4444-555555555555'
|
||||||
|
profilePayload = {
|
||||||
|
job: jobRow({ id: SPARSE_ID, employment_type: null, optional_skills: [] }),
|
||||||
|
suggested: 0, top_match: 0, top_score: null,
|
||||||
|
bands: { 'Strong Match': 0, 'Potential Match': 0, 'Weak Match': 0 },
|
||||||
|
candidates: [],
|
||||||
|
}
|
||||||
|
container = dom.window.document.createElement('div')
|
||||||
|
dom.window.document.body.appendChild(container)
|
||||||
|
m = await mod.mountRoute(`/job/${SPARSE_ID}?tab=suggested`, container)
|
||||||
|
await m.settle(60)
|
||||||
|
const sparseRole = m.find('.job-hero .ph-role')?.textContent || ''
|
||||||
|
check('no employment type → hero line omits it', sparseRole === 'GEO · Multi-region', `role="${sparseRole}"`)
|
||||||
|
check('?tab=suggested opens that tab, with an empty state', m.text().includes('No suggested candidates yet'))
|
||||||
|
await m.click(m.findByText('[role="tab"]', 'Details'))
|
||||||
|
check('no optional skills → no Optional Skills section', !m.text().includes('Optional Skills'))
|
||||||
|
await m.unmount()
|
||||||
|
container.remove()
|
||||||
|
|
||||||
|
check('no console errors', errors.length === 0, errors[0]?.split('\n').slice(0, 3).join(' | '))
|
||||||
|
} finally {
|
||||||
|
console.error = origError
|
||||||
|
rmSync(outDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(failed ? `\n${failed} job profile check(s) FAILED` : '\nAll job profile checks passed')
|
||||||
|
process.exit(failed ? 1 : 0)
|
||||||
|
|
@ -8,6 +8,13 @@ server {
|
||||||
# CV / multipart uploads (MAX_PDF_SIZE_MB is 10; leave headroom for form fields).
|
# CV / multipart uploads (MAX_PDF_SIZE_MB is 10; leave headroom for form fields).
|
||||||
client_max_body_size 25m;
|
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.
|
# Security headers on every response.
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header X-Frame-Options DENY always;
|
add_header X-Frame-Options DENY always;
|
||||||
|
|
@ -23,7 +30,7 @@ server {
|
||||||
|
|
||||||
# SPA page roots that also prefix API calls — sub-path required.
|
# SPA page roots that also prefix API calls — sub-path required.
|
||||||
location ~ ^/(jobs|inbox|pipeline|tasks|assessments|offers|managers|analytics|notifications)/ {
|
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_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
@ -37,8 +44,8 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
# API-only prefixes (no SPA page at the bare path).
|
# 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)(/|$) {
|
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 http://backend-api:8000;
|
proxy_pass $backend_api;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
"test:cvbank": "node cvbank.test.mjs",
|
"test:cvbank": "node cvbank.test.mjs",
|
||||||
"test:browse": "node candidate-browse.test.mjs",
|
"test:browse": "node candidate-browse.test.mjs",
|
||||||
"test:mobile": "node mobile.test.mjs",
|
"test:mobile": "node mobile.test.mjs",
|
||||||
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs && node candidate-browse.test.mjs"
|
"test:jobprofile": "node job-profile.test.mjs",
|
||||||
|
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs && node job-profile.test.mjs && node candidate-browse.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const SCREENS = {
|
||||||
assessments: lazy(() => import('./screens/Assessments')),
|
assessments: lazy(() => import('./screens/Assessments')),
|
||||||
offers: lazy(() => import('./screens/Offers')),
|
offers: lazy(() => import('./screens/Offers')),
|
||||||
managers: lazy(() => import('./screens/Managers')),
|
managers: lazy(() => import('./screens/Managers')),
|
||||||
|
departments: lazy(() => import('./screens/Departments')),
|
||||||
calendar: lazy(() => import('./screens/Calendar')),
|
calendar: lazy(() => import('./screens/Calendar')),
|
||||||
reports: lazy(() => import('./screens/Reports')),
|
reports: lazy(() => import('./screens/Reports')),
|
||||||
analytics: lazy(() => import('./screens/Analytics')),
|
analytics: lazy(() => import('./screens/Analytics')),
|
||||||
|
|
@ -54,6 +55,7 @@ function LegacyCandidateRedirect() {
|
||||||
const to = `/candidate/${encodeURIComponent(userId)}`
|
const to = `/candidate/${encodeURIComponent(userId)}`
|
||||||
return <Navigate to={tab ? `${to}?tab=${encodeURIComponent(tab)}` : to} replace />
|
return <Navigate to={tab ? `${to}?tab=${encodeURIComponent(tab)}` : to} replace />
|
||||||
}
|
}
|
||||||
|
const JobProfile = lazy(() => import('./screens/JobProfile'))
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -103,6 +105,14 @@ export default function App() {
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/job/:jobId"
|
||||||
|
element={
|
||||||
|
<RequireAuth permission="jobs.view">
|
||||||
|
<JobProfile />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/candidates/:userId"
|
path="/candidates/:userId"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import Notifications from '../screens/Notifications'
|
||||||
import Rbac from '../screens/Rbac'
|
import Rbac from '../screens/Rbac'
|
||||||
import Settings from '../screens/Settings'
|
import Settings from '../screens/Settings'
|
||||||
import Help from '../screens/Help'
|
import Help from '../screens/Help'
|
||||||
|
import JobProfile from '../screens/JobProfile'
|
||||||
|
|
||||||
const SCREENS = {
|
const SCREENS = {
|
||||||
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
||||||
|
|
@ -68,6 +69,11 @@ const PAGES = {
|
||||||
'/auth/confirm-email': ConfirmEmail,
|
'/auth/confirm-email': ConfirmEmail,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detail pages live outside the ROUTES table (parameterized path), as in App.jsx.
|
||||||
|
const DETAIL_PAGES = [
|
||||||
|
{ pattern: '/job/:jobId', prefix: '/job/', Screen: JobProfile },
|
||||||
|
]
|
||||||
|
|
||||||
export const ALL_ROUTES = [
|
export const ALL_ROUTES = [
|
||||||
...Object.keys(PAGES),
|
...Object.keys(PAGES),
|
||||||
...TABLE.map((r) => `/${r.path}`),
|
...TABLE.map((r) => `/${r.path}`),
|
||||||
|
|
@ -112,9 +118,21 @@ export async function mountRoute(path, container) {
|
||||||
})
|
})
|
||||||
await settle()
|
await settle()
|
||||||
}
|
}
|
||||||
|
// React tracks an input's value through the native setter; assigning
|
||||||
|
// el.value directly is invisible to onChange, so go through the prototype.
|
||||||
|
const type = async (el, value) => {
|
||||||
|
if (!el) throw new Error('type: element not found')
|
||||||
|
const win = el.ownerDocument.defaultView
|
||||||
|
await act(async () => {
|
||||||
|
Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, 'value').set.call(el, value)
|
||||||
|
el.dispatchEvent(new win.Event('input', { bubbles: true }))
|
||||||
|
})
|
||||||
|
await settle()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
settle,
|
settle,
|
||||||
|
type,
|
||||||
click,
|
click,
|
||||||
selectOption,
|
selectOption,
|
||||||
html: () => container.innerHTML,
|
html: () => container.innerHTML,
|
||||||
|
|
@ -129,15 +147,17 @@ export async function mountRoute(path, container) {
|
||||||
function routeTree(path) {
|
function routeTree(path) {
|
||||||
const h = React.createElement
|
const h = React.createElement
|
||||||
const isAuth = path.startsWith('/auth/')
|
const isAuth = path.startsWith('/auth/')
|
||||||
const def = TABLE.find((r) => `/${r.path}` === path)
|
const detail = DETAIL_PAGES.find((d) => path.startsWith(d.prefix))
|
||||||
const Screen = isAuth ? PAGES[path] : SCREENS[def.path]
|
const def = TABLE.find((r) => `/${r.path}` === path.split('?')[0])
|
||||||
|
const Screen = isAuth ? PAGES[path] : detail ? detail.Screen : SCREENS[def.path]
|
||||||
|
const routePath = detail ? detail.pattern : path.split('?')[0]
|
||||||
|
|
||||||
const inner = isAuth
|
const inner = isAuth
|
||||||
? h(Route, { path, element: h(Screen) })
|
? h(Route, { path, element: h(Screen) })
|
||||||
: h(
|
: h(
|
||||||
Route,
|
Route,
|
||||||
{ element: h(RequireAuth, null, h(AppLayout)) },
|
{ element: h(RequireAuth, null, h(AppLayout)) },
|
||||||
h(Route, { path, element: h(Screen) }),
|
h(Route, { path: routePath, element: h(Screen) }),
|
||||||
)
|
)
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
}
|
||||||
|
|
@ -64,7 +64,24 @@ function experienceLabel(min, max) {
|
||||||
return `${min ?? max}+ years`
|
return `${min ?? max}+ years`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Job ownership arrives in one of two shapes. serialize_job_row (GET /jobs/fetch,
|
||||||
|
GET /jobs/profile/fetch) sends the two roles as separate {id: name} objects:
|
||||||
|
|
||||||
|
"recruiters": {"ed9e…": "Nida Khan"}, "hiring_manager": {"77aa…": "Amara Osei"}
|
||||||
|
|
||||||
|
serialize_job_post — the inbox / matching / picker payload — still sends the
|
||||||
|
older flat keys (current_recruiter_ids, recruiter_names, recruiters as an
|
||||||
|
ARRAY of {id, name}, hiring_manager_id / hiring_manager_name). Both are read
|
||||||
|
here so one mapper serves every caller; the Array check is what tells the two
|
||||||
|
`recruiters` shapes apart. */
|
||||||
|
function recruiterMap(row) {
|
||||||
|
const value = row?.recruiters
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
function recruiterIdsFrom(row) {
|
function recruiterIdsFrom(row) {
|
||||||
|
const map = recruiterMap(row)
|
||||||
|
if (map) return Object.keys(map).map(String)
|
||||||
const ids = Array.isArray(row?.current_recruiter_ids)
|
const ids = Array.isArray(row?.current_recruiter_ids)
|
||||||
? row.current_recruiter_ids.filter(Boolean).map(String)
|
? row.current_recruiter_ids.filter(Boolean).map(String)
|
||||||
: []
|
: []
|
||||||
|
|
@ -73,6 +90,8 @@ function recruiterIdsFrom(row) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function recruiterNamesFrom(row) {
|
function recruiterNamesFrom(row) {
|
||||||
|
const map = recruiterMap(row)
|
||||||
|
if (map) return Object.values(map).filter(Boolean)
|
||||||
if (Array.isArray(row?.recruiter_names) && row.recruiter_names.length) {
|
if (Array.isArray(row?.recruiter_names) && row.recruiter_names.length) {
|
||||||
return row.recruiter_names.filter(Boolean)
|
return row.recruiter_names.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
@ -82,14 +101,29 @@ function recruiterNamesFrom(row) {
|
||||||
return row?.recruiter_name ? [row.recruiter_name] : []
|
return row?.recruiter_name ? [row.recruiter_name] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** {id, name} of the hiring manager, from either payload shape. */
|
||||||
|
function hiringManagerFrom(row) {
|
||||||
|
const value = row?.hiring_manager
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const [id, name] = Object.entries(value)[0] ?? []
|
||||||
|
if (id) return { id: String(id), name: name ?? null }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row?.hiring_manager_id ? String(row.hiring_manager_id) : null,
|
||||||
|
name: row?.hiring_manager_name ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** API row -> what the Jobs table and detail modal render. */
|
/** API row -> what the Jobs table and detail modal render. */
|
||||||
export function toJobView(row) {
|
export function toJobView(row) {
|
||||||
const recruiterIds = recruiterIdsFrom(row)
|
const recruiterIds = recruiterIdsFrom(row)
|
||||||
const recruiterNames = recruiterNamesFrom(row)
|
const recruiterNames = recruiterNamesFrom(row)
|
||||||
|
const manager = hiringManagerFrom(row)
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
department: row.department,
|
department: row.department,
|
||||||
|
departmentId: row.department_id || null,
|
||||||
location: row.location,
|
location: row.location,
|
||||||
type: row.employment_type,
|
type: row.employment_type,
|
||||||
vacancies: row.vacancies,
|
vacancies: row.vacancies,
|
||||||
|
|
@ -100,8 +134,8 @@ export function toJobView(row) {
|
||||||
recruiterId: recruiterIds[0] || null,
|
recruiterId: recruiterIds[0] || null,
|
||||||
recruiterIds,
|
recruiterIds,
|
||||||
recruiterNames,
|
recruiterNames,
|
||||||
hiringManager: row.hiring_manager_name,
|
hiringManager: manager.name,
|
||||||
hiringManagerId: row.hiring_manager_id,
|
hiringManagerId: manager.id,
|
||||||
createdByName: row.created_by_name,
|
createdByName: row.created_by_name,
|
||||||
applicantCount: row.applicant_count ?? 0,
|
applicantCount: row.applicant_count ?? 0,
|
||||||
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.
|
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.
|
||||||
|
|
@ -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. */
|
/** Status-change audit for one requisition — GET /jobs/status-history/fetch. */
|
||||||
export function listStatusHistory(jobPostId) {
|
export function listStatusHistory(jobPostId) {
|
||||||
return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } })
|
return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } })
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ export function list() {
|
||||||
return request('/forms/requisition/fetch')
|
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) {
|
export function getById(formId) {
|
||||||
return request('/forms/requisition/fetch', { params: { form_id: formId } })
|
return request('/forms/requisition/fetch', { params: { form_id: formId } })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export const ROUTES = [
|
||||||
{ path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' },
|
{ path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' },
|
||||||
{ path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.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: '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' },
|
{ path: 'calendar', title: 'Calendar', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
|
||||||
|
|
||||||
// --- Insights ---
|
// --- Insights ---
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
export const MODULES = [
|
export const MODULES = [
|
||||||
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
|
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
|
||||||
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks',
|
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks',
|
||||||
'talent', 'requisitions',
|
'talent', 'requisitions', 'department',
|
||||||
]
|
]
|
||||||
|
|
||||||
export const ACTIONS = [
|
export const ACTIONS = [
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ export const qk = {
|
||||||
list: (p = {}) => ['jobs', 'list', p],
|
list: (p = {}) => ['jobs', 'list', p],
|
||||||
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
|
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
|
||||||
statusHistory: (id) => ['jobs', 'status-history', id],
|
statusHistory: (id) => ['jobs', 'status-history', id],
|
||||||
|
profile: (id, p = {}) => ['jobs', 'profile', id, p],
|
||||||
stats: (p = {}) => ['jobs', 'stats', p],
|
stats: (p = {}) => ['jobs', 'stats', p],
|
||||||
},
|
},
|
||||||
talent: {
|
talent: {
|
||||||
|
|
@ -143,9 +144,17 @@ export const qk = {
|
||||||
requisitions: {
|
requisitions: {
|
||||||
all: () => ['requisitions'],
|
all: () => ['requisitions'],
|
||||||
list: () => ['requisitions', 'list'],
|
list: () => ['requisitions', 'list'],
|
||||||
|
openCount: () => ['requisitions', 'open-count'],
|
||||||
detail: (id) => ['requisitions', 'detail', id],
|
detail: (id) => ['requisitions', 'detail', id],
|
||||||
search: (q = '', jobPostId = null) => ['requisitions', 'search', q, jobPostId || null],
|
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: {
|
interviews: {
|
||||||
all: () => ['interviews'],
|
all: () => ['interviews'],
|
||||||
range: (p = {}) => ['interviews', 'range', p],
|
range: (p = {}) => ['interviews', 'range', p],
|
||||||
|
|
|
||||||
|
|
@ -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 ? 'Couldn’t 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="Couldn’t 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 ? 'Couldn’t 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()}>
|
||||||
|
Couldn’t 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,7 @@ const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' }
|
||||||
const PAGE_SIZE_MAX = 100
|
const PAGE_SIZE_MAX = 100
|
||||||
|
|
||||||
/** Extraction keeps the resume's own casing; SHOUTED headers read badly on cards. */
|
/** Extraction keeps the resume's own casing; SHOUTED headers read badly on cards. */
|
||||||
function displayName(name) {
|
export function displayName(name) {
|
||||||
if (!name || /[a-z]/.test(name)) return name
|
if (!name || /[a-z]/.test(name)) return name
|
||||||
return name.toLowerCase().replace(/\p{L}+/gu, (w) => w[0].toUpperCase() + w.slice(1))
|
return name.toLowerCase().replace(/\p{L}+/gu, (w) => w[0].toUpperCase() + w.slice(1))
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +42,7 @@ function ringColor(score) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The 120px .ats-ring shrunk to card size — same conic trick, no new CSS. */
|
/** The 120px .ats-ring shrunk to card size — same conic trick, no new CSS. */
|
||||||
function MiniRing({ score, size = 46 }) {
|
export function MiniRing({ score, size = 46 }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,534 @@
|
||||||
|
/* ============================================================
|
||||||
|
JobProfile — full-page job profile at /job/:jobId (replaces the Jobs
|
||||||
|
board's detail modal, per the "Job Profile" design).
|
||||||
|
|
||||||
|
One request, GET /jobs/profile/fetch, feeds the whole page: the requisition
|
||||||
|
row, the suggested candidates and the Suggested / Top Match header stats.
|
||||||
|
Suggested = the newest ats_results score per person for this job; Top Match
|
||||||
|
= how many of them sit in the Strong Match band. Search and the "Show" size
|
||||||
|
go to the API as `search` / `top`; band filter and sort then run client-side
|
||||||
|
over the rows that come back.
|
||||||
|
|
||||||
|
Tabs: Details · Suggested Candidates · History (?tab= deep-links a tab).
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { Tabs } from '../ui/Tabs'
|
||||||
|
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
|
||||||
|
import { Avatar, Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
|
||||||
|
import { useToast } from '../ui/Toast'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import { qk } from '../lib/queryKeys'
|
||||||
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
|
import { platformLabel } from '../lib/platforms'
|
||||||
|
import { fmtShort } from '../lib/format'
|
||||||
|
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||||
|
import * as jobsApi from '../api/jobs'
|
||||||
|
import * as assignmentsApi from '../api/assignments'
|
||||||
|
import * as offersApi from '../api/offers'
|
||||||
|
import { EditJobForm, JobCover, JobHistory, JobOwnership, SECTION_LABEL } from './Jobs'
|
||||||
|
import { MiniRing, ScoredCandidateDetail, displayName } from './JobCandidates'
|
||||||
|
|
||||||
|
/* The department name as the hero line and the Details tab show it. Kept local
|
||||||
|
rather than imported from Jobs.jsx so this page does not break when that
|
||||||
|
screen's helpers are reshuffled. */
|
||||||
|
function deptValue(j) {
|
||||||
|
return String(j.requisitionDepartment || j.department || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function deptLabel(j) {
|
||||||
|
return deptValue(j) || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAB_KEYS = ['details', 'suggested', 'history']
|
||||||
|
|
||||||
|
const SORTS = [
|
||||||
|
{ value: 'score', label: 'Best Match → Worst Match' },
|
||||||
|
{ value: 'recent', label: 'Most Recent' },
|
||||||
|
{ value: 'name', label: 'A → Z' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function JobProfile() {
|
||||||
|
const { jobId } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { can } = useAuth()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const canEdit = can('jobs.edit')
|
||||||
|
const canDelete = can('jobs.delete')
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
|
||||||
|
const requestedTab = String(searchParams.get('tab') || '').toLowerCase()
|
||||||
|
const tab = TAB_KEYS.includes(requestedTab) ? requestedTab : 'details'
|
||||||
|
const setTab = (next) => {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
if (next === 'details') params.delete('tab')
|
||||||
|
else params.set('tab', next)
|
||||||
|
setSearchParams(params, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggested-list request params. They live on the page, not in the tab,
|
||||||
|
// because the one profile call carries them.
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [top, setTop] = useState(DEFAULT_PAGE_SIZE)
|
||||||
|
const [skip, setSkip] = useState(0)
|
||||||
|
const params = { search: search.trim() || undefined, top, skip }
|
||||||
|
|
||||||
|
const profileQuery = useQuery({
|
||||||
|
queryKey: qk.jobs.profile(jobId, params),
|
||||||
|
queryFn: async () => (await jobsApi.fetchProfile(jobId, params))?.data ?? null,
|
||||||
|
enabled: Boolean(jobId),
|
||||||
|
retry: false,
|
||||||
|
// A new search or size must not blank the page already on screen.
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
})
|
||||||
|
const profile = profileQuery.data
|
||||||
|
const job = useMemo(() => (profile?.job ? jobsApi.toJobView(profile.job) : null), [profile])
|
||||||
|
const suggested = useMemo(
|
||||||
|
() => (Array.isArray(profile?.candidates) ? profile.candidates.map(jobsApi.toSuggestedView) : []),
|
||||||
|
[profile],
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusesQuery = useQuery({
|
||||||
|
queryKey: qk.jobs.requisitionStatuses(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await jobsApi.listRequisitionStatuses()
|
||||||
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
|
return rows.length ? rows : jobsApi.REQUISITION_STATUSES
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const statusLabels = (statusesQuery.data ?? jobsApi.REQUISITION_STATUSES).map((s) => s.label)
|
||||||
|
|
||||||
|
// Shared key documented on jobsApi.fetchDepartmentOptions — same fetcher everywhere.
|
||||||
|
const departmentsQuery = useQuery({
|
||||||
|
queryKey: qk.jobs.list({ scope: 'departments' }),
|
||||||
|
queryFn: jobsApi.fetchDepartmentOptions,
|
||||||
|
enabled: editing,
|
||||||
|
})
|
||||||
|
|
||||||
|
const historyQuery = useQuery({
|
||||||
|
queryKey: qk.assignments.job(jobId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await assignmentsApi.listJob(jobId, { currentOnly: false })
|
||||||
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
|
return rows.map((r) => assignmentsApi.toAssignmentView(r))
|
||||||
|
},
|
||||||
|
enabled: Boolean(jobId),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: qk.jobs.statusHistory(jobId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await jobsApi.listStatusHistory(jobId)
|
||||||
|
return Array.isArray(res?.data) ? res.data : []
|
||||||
|
},
|
||||||
|
enabled: Boolean(jobId),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const offersQuery = useQuery({
|
||||||
|
queryKey: qk.offers.list({ jobPostId: jobId, top: 200 }),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await offersApi.list({ jobPostId: jobId, top: 200 })
|
||||||
|
return Array.isArray(res?.data) ? res.data : []
|
||||||
|
},
|
||||||
|
enabled: Boolean(jobId),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0) + (offersQuery.data?.length ?? 0)
|
||||||
|
|
||||||
|
const updateJob = useMutation({
|
||||||
|
mutationFn: (body) => jobsApi.update(jobId, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||||
|
setEditing(false)
|
||||||
|
toast('Job updated', 'success')
|
||||||
|
},
|
||||||
|
onError: (err) => toast(friendlyAuthError(err, 'Could not update the job'), 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const setJobStatus = useMutation({
|
||||||
|
mutationFn: (status) => jobsApi.setStatus(jobId, status),
|
||||||
|
onSuccess: (_d, status) => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.notifications.all() })
|
||||||
|
toast(`Status set to ${status}`, 'success')
|
||||||
|
},
|
||||||
|
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteJob = useMutation({
|
||||||
|
mutationFn: () => jobsApi.remove(jobId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||||
|
toast('Job deleted', 'success')
|
||||||
|
navigate('/jobs', { replace: true })
|
||||||
|
},
|
||||||
|
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the job'), 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const goBack = () => (window.history.length > 1 ? navigate(-1) : navigate('/jobs'))
|
||||||
|
|
||||||
|
if (profileQuery.isPending || profileQuery.isError || !job) {
|
||||||
|
return (
|
||||||
|
<div className="cand-page">
|
||||||
|
<div className="cand-page-bar">
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={goBack}><Icon name="chevron-left" /> Back</button>
|
||||||
|
<div className="cand-page-crumb"><Link to="/jobs">Jobs</Link></div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
{profileQuery.isPending ? (
|
||||||
|
<SkeletonRows rows={6} />
|
||||||
|
) : (
|
||||||
|
<EmptyState icon="briefcase" title="Couldn’t load this job">
|
||||||
|
{friendlyAuthError(profileQuery.error, 'The job may have been deleted or is outside your scope.')}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLine = [deptValue(job), job.location, job.type].filter(Boolean).join(' · ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cand-page">
|
||||||
|
<div className="cand-page-bar">
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={goBack}><Icon name="chevron-left" /> Back</button>
|
||||||
|
<div className="cand-page-crumb">
|
||||||
|
<Link to="/jobs">Jobs</Link> <span>›</span> <strong>{job.title}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="profile-hero job-hero">
|
||||||
|
<span className="job-hero-icn"><Icon name="briefcase" /></span>
|
||||||
|
<div className="job-hero-id">
|
||||||
|
<div className="ph-name">{job.title}</div>
|
||||||
|
<div className="ph-role">{roleLine || '—'}</div>
|
||||||
|
<div className="ph-tags">
|
||||||
|
<Badge>{job.status}</Badge>
|
||||||
|
<Badge className="b-gray">{job.vacancies ?? 0} {job.vacancies === 1 ? 'Vacancy' : 'Vacancies'}</Badge>
|
||||||
|
<Badge className="b-gray">{job.applicantCount} {job.applicantCount === 1 ? 'Applicant' : 'Applicants'}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="job-hero-actions">
|
||||||
|
<div className="hero-stat">
|
||||||
|
<div className="v">{profile.total ?? profile.suggested ?? 0}</div>
|
||||||
|
<div className="l">Suggested</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="hero-stat"
|
||||||
|
title={profile.top_score != null ? `Candidates in the Strong Match band · best score ${profile.top_score}` : 'Candidates in the Strong Match band'}
|
||||||
|
>
|
||||||
|
<div className="v" style={{ color: profile.top_match ? 'var(--success)' : undefined }}>{profile.top_match ?? 0}</div>
|
||||||
|
<div className="l">Top Match</div>
|
||||||
|
</div>
|
||||||
|
{canEdit ? (
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
aria-label="Requisition status"
|
||||||
|
value={job.status}
|
||||||
|
disabled={setJobStatus.isPending}
|
||||||
|
onChange={(e) => setJobStatus.mutate(e.target.value)}
|
||||||
|
>
|
||||||
|
{statusLabels.map((s) => <option key={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ color: 'var(--danger)' }}
|
||||||
|
disabled={deleteJob.isPending}
|
||||||
|
onClick={() => { if (window.confirm(`Delete “${job.title}”?`)) deleteJob.mutate() }}
|
||||||
|
>
|
||||||
|
<Icon name="trash" /> {deleteJob.isPending ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<button className="btn btn-secondary" onClick={() => setEditing(true)}><Icon name="edit" /> Edit</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-primary" onClick={() => navigate('/jobboard', { state: { publishJob: job.id } })}>
|
||||||
|
<Icon name="send" /> Publish
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
tabs={[
|
||||||
|
{ key: 'details', label: 'Details' },
|
||||||
|
{ key: 'suggested', label: 'Suggested Candidates', count: (profile.total ?? profile.suggested) || undefined },
|
||||||
|
{ key: 'history', label: 'History', count: historyCount || undefined },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="tab-pane active">
|
||||||
|
{tab === 'details' && <JobDetailsTab job={job} canEdit={canEdit} />}
|
||||||
|
{tab === 'suggested' && (
|
||||||
|
<SuggestedCandidatesTab
|
||||||
|
job={job}
|
||||||
|
profile={profile}
|
||||||
|
candidates={suggested}
|
||||||
|
search={search}
|
||||||
|
setSearch={(v) => { setSearch(v); setSkip(0) }}
|
||||||
|
top={top}
|
||||||
|
skip={skip}
|
||||||
|
setSkip={setSkip}
|
||||||
|
setTop={(n) => {
|
||||||
|
const page = pageAfterSizeChange(Math.floor(skip / top) + 1, profile.total ?? 0, n)
|
||||||
|
setTop(n)
|
||||||
|
setSkip((page - 1) * n)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} offersQuery={offersQuery} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<EditJobForm
|
||||||
|
job={job}
|
||||||
|
departmentOptions={departmentsQuery.data ?? []}
|
||||||
|
busy={updateJob.isPending}
|
||||||
|
onClose={() => setEditing(false)}
|
||||||
|
onSubmit={(body) => updateJob.mutate(body)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobDetailsTab({ job: j, canEdit }) {
|
||||||
|
const created = [j.created ? fmtShort(j.created) : null, j.createdByName ? `by ${j.createdByName}` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<JobCover jobId={j.id} />
|
||||||
|
|
||||||
|
<div className="info-grid mb-18">
|
||||||
|
<div className="info-item"><div className="il">Department</div><div className="iv">{deptLabel(j)}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Created</div><div className="iv">{created || '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Assigned Recruiters</div><div className="iv">{j.recruiter || '—'}</div></div>
|
||||||
|
{j.closedAt && (
|
||||||
|
<div className="info-item"><div className="il">Closed at</div><div className="iv">{fmtShort(j.closedAt)}</div></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<JobOwnership job={j} canEdit={canEdit} />
|
||||||
|
|
||||||
|
{j.description && (
|
||||||
|
<>
|
||||||
|
<div className="divider" />
|
||||||
|
<div className="mb-16">
|
||||||
|
<div style={SECTION_LABEL}>Description</div>
|
||||||
|
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{j.skills.length > 0 && (
|
||||||
|
<div className="mb-16">
|
||||||
|
<div style={SECTION_LABEL}>Required Skills</div>
|
||||||
|
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{j.optionalSkills.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={SECTION_LABEL}>Optional Skills</div>
|
||||||
|
<div className="k-tags">
|
||||||
|
{j.optionalSkills.map((s) => (
|
||||||
|
<span className="tag tag-optional" key={s}><Icon name="star" /> {s}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuggestedCandidatesTab({ job, profile, candidates, search, setSearch, top, skip, setSkip, setTop }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [band, setBand] = useState('')
|
||||||
|
const [sort, setSort] = useState('score')
|
||||||
|
const [viewing, setViewing] = useState(null)
|
||||||
|
|
||||||
|
// Search already ran on the server; band and sort apply to what came back.
|
||||||
|
const list = useMemo(() => {
|
||||||
|
let rows = candidates.filter((c) => !band || c.band === band)
|
||||||
|
if (sort === 'name') rows = [...rows].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
else if (sort === 'recent') rows = [...rows].sort((a, b) => (b.scoredAt?.getTime() ?? 0) - (a.scoredAt?.getTime() ?? 0))
|
||||||
|
else rows = [...rows].sort((a, b) => (b.score ?? -1) - (a.score ?? -1))
|
||||||
|
return rows
|
||||||
|
}, [candidates, band, sort])
|
||||||
|
|
||||||
|
// Paging comes from the API: `total` counts every match, the page holds `top`.
|
||||||
|
const total = profile.total ?? candidates.length
|
||||||
|
const pages = Math.max(1, Math.ceil(total / top))
|
||||||
|
const page = Math.floor(skip / top) + 1
|
||||||
|
const from = total === 0 ? 0 : skip + 1
|
||||||
|
const to = Math.min(skip + candidates.length, total)
|
||||||
|
const bands = profile.bands || {}
|
||||||
|
|
||||||
|
function open(c) {
|
||||||
|
if (c.userId) {
|
||||||
|
navigate(`/candidate/${c.userId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setViewing(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="cand-sub">
|
||||||
|
{total} candidate{total === 1 ? '' : 's'} suggested · vs {job.title}
|
||||||
|
{sort === 'score' && <span className="auto-tag">Sorted: Best → Worst</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="toolbar-search">
|
||||||
|
<Icon name="search" />
|
||||||
|
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search name, email, title, company…" />
|
||||||
|
</div>
|
||||||
|
<select className="select" aria-label="Match band" value={band} onChange={(e) => setBand(e.target.value)}>
|
||||||
|
<option value="">All results</option>
|
||||||
|
{jobsApi.MATCH_BANDS.map((b) => (
|
||||||
|
<option key={b} value={b}>{b} ({bands[b] ?? 0})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="spacer" />
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<label className="text-muted text-sm" htmlFor="suggested-sort">Sort:</label>
|
||||||
|
<select
|
||||||
|
id="suggested-sort"
|
||||||
|
className={`select${sort === 'score' ? ' active-filter' : ''}`}
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => setSort(e.target.value)}
|
||||||
|
>
|
||||||
|
{SORTS.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{list.length === 0 ? (
|
||||||
|
candidates.length === 0 && !search.trim() ? (
|
||||||
|
<EmptyState icon="users" title="No suggested candidates yet">
|
||||||
|
Candidates appear here once their CVs are ATS-scored against this job.
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No matches">Try a different search or band.</EmptyState>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="grid g-3">
|
||||||
|
{list.map((c, i) => (
|
||||||
|
<SuggestedCard key={c.id} c={c} rank={sort === 'score' ? skip + i + 1 : null} onOpen={() => open(c)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{total > 0 && (
|
||||||
|
<Pagination
|
||||||
|
from={from}
|
||||||
|
to={to}
|
||||||
|
total={total}
|
||||||
|
page={page}
|
||||||
|
pages={pages}
|
||||||
|
setPage={(p) => setSkip((p - 1) * top)}
|
||||||
|
pageButtons={pageWindow(page, pages)}
|
||||||
|
pageSize={top}
|
||||||
|
pageSizeMax={100}
|
||||||
|
onPageSizeChange={setTop}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewing && (
|
||||||
|
<ScoredCandidateDetail
|
||||||
|
candidate={{
|
||||||
|
id: viewing.id,
|
||||||
|
name: viewing.name,
|
||||||
|
filename: viewing.email,
|
||||||
|
source: viewing.sourceLabel,
|
||||||
|
currentTitle: viewing.currentTitle,
|
||||||
|
currentCompany: viewing.currentCompany,
|
||||||
|
experience: viewing.experience,
|
||||||
|
aiScore: viewing.score,
|
||||||
|
matchedSkills: viewing.matchedSkills,
|
||||||
|
missingSkills: viewing.missingSkills,
|
||||||
|
critique: viewing.summary,
|
||||||
|
scoringStatus: 'completed',
|
||||||
|
applied: viewing.scoredAt,
|
||||||
|
}}
|
||||||
|
jobTitle={job.title}
|
||||||
|
onClose={() => setViewing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuggestedCard({ c, rank, onOpen }) {
|
||||||
|
const matched = c.matchedSkills.slice(0, 3)
|
||||||
|
const missing = c.missingSkills.slice(0, 2)
|
||||||
|
const optional = c.optionalMatched.slice(0, 3)
|
||||||
|
const more = (c.matchedSkills.length - matched.length)
|
||||||
|
+ (c.missingSkills.length - missing.length)
|
||||||
|
+ (c.optionalMatched.length - optional.length)
|
||||||
|
const roleLine = [c.currentTitle, c.currentCompany].filter(Boolean).join(' at ')
|
||||||
|
const foot = [c.experience != null ? `${c.experience} yrs` : null, c.currentCompany].filter(Boolean).join(' · ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`card cand-card${rank ? ' ranked' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onOpen}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen() } }}
|
||||||
|
>
|
||||||
|
<div className="card-body">
|
||||||
|
{rank && <span className="cand-rank">{rank}</span>}
|
||||||
|
<div className="cand-head">
|
||||||
|
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
|
||||||
|
<div className="cand-id">
|
||||||
|
<div className="cand-name">{displayName(c.name)}</div>
|
||||||
|
<div className="cand-role">{roleLine || c.email || '—'}</div>
|
||||||
|
</div>
|
||||||
|
{c.score != null && <MiniRing score={c.score} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cand-skills">
|
||||||
|
{matched.map((s) => <span className="cand-chip ok" key={`m-${s}`}><Icon name="check" /> {s}</span>)}
|
||||||
|
{missing.map((s) => <span className="cand-chip miss" key={`x-${s}`}><Icon name="x" /> {s}</span>)}
|
||||||
|
{optional.map((s) => (
|
||||||
|
<span className="cand-chip opt" key={`o-${s}`} title="Optional skill from the job post"><Icon name="star" /> {s}</span>
|
||||||
|
))}
|
||||||
|
{more > 0 && <span className="cand-chip more">+{more} more</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="cand-crit">{c.summary || <span className="text-muted">No summary</span>}</p>
|
||||||
|
|
||||||
|
<div className="cand-foot">
|
||||||
|
<span className="cand-company">{foot || '—'}</span>
|
||||||
|
<Badge className="b-gray">{c.sourceLabel}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,8 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
|
||||||
import AiFieldAssist from '../ui/AiFieldAssist'
|
import AiFieldAssist from '../ui/AiFieldAssist'
|
||||||
import DataTable from '../ui/DataTable'
|
import DataTable from '../ui/DataTable'
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
|
import SearchSelect from '../ui/SearchSelect'
|
||||||
import PageHeader from '../ui/PageHeader'
|
import PageHeader from '../ui/PageHeader'
|
||||||
import { Tabs } from '../ui/Tabs'
|
|
||||||
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
||||||
import { useToast } from '../ui/Toast'
|
import { useToast } from '../ui/Toast'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
|
@ -25,10 +25,10 @@ import { friendlyAuthError } from '../lib/errors'
|
||||||
import { platformLabel } from '../lib/platforms'
|
import { platformLabel } from '../lib/platforms'
|
||||||
import * as jobsApi from '../api/jobs'
|
import * as jobsApi from '../api/jobs'
|
||||||
import * as jobPostsApi from '../api/jobPosts'
|
import * as jobPostsApi from '../api/jobPosts'
|
||||||
import * as assignmentsApi from '../api/assignments'
|
|
||||||
import * as tasksApi from '../api/tasks'
|
import * as tasksApi from '../api/tasks'
|
||||||
import * as usersApi from '../api/users'
|
import * as usersApi from '../api/users'
|
||||||
import * as requisitionsApi from '../api/requisitions'
|
import * as requisitionsApi from '../api/requisitions'
|
||||||
|
import * as departmentsApi from '../api/departments'
|
||||||
import * as offersApi from '../api/offers'
|
import * as offersApi from '../api/offers'
|
||||||
import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format'
|
import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format'
|
||||||
import { empTypes } from '../data/seed'
|
import { empTypes } from '../data/seed'
|
||||||
|
|
@ -46,7 +46,7 @@ function deptValue(j) {
|
||||||
return String(j.requisitionDepartment || j.department || '').trim()
|
return String(j.requisitionDepartment || j.department || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deptLabel(j) {
|
export function deptLabel(j) {
|
||||||
return deptValue(j) || '—'
|
return deptValue(j) || '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,31 +107,27 @@ export default function Jobs() {
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const [type, setType] = useState('')
|
const [type, setType] = useState('')
|
||||||
|
|
||||||
const [viewing, setViewing] = useState(null)
|
|
||||||
const [viewingTab, setViewingTab] = useState('details')
|
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
const canEdit = can('jobs.edit')
|
const canEdit = can('jobs.edit')
|
||||||
const canDelete = can('jobs.delete')
|
const openJob = (id, tab) => navigate(`/job/${id}${tab === 'history' ? '?tab=history' : ''}`)
|
||||||
|
|
||||||
// Deep-link intents from notifications, global search, the dashboard and the
|
// Deep-link intents from notifications, global search, the dashboard and the
|
||||||
// manager portal. Consume once and replace history: jobs refetch after a
|
// manager portal. Consume once and replace history: jobs refetch after a
|
||||||
// status PATCH used to replay openCreate and pop the create modal over the
|
// status PATCH used to replay openCreate and pop the create modal. A job
|
||||||
// detail view. `/jobs?job=` / `?tab=history` is the notification target.
|
// target (`/jobs?job=` / `?tab=history`, or state.openJob) now redirects to
|
||||||
|
// the full job profile page at /job/:jobId.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const st = location.state
|
const st = location.state
|
||||||
const jobId = searchParams.get('job') || st?.openJob
|
const jobId = searchParams.get('job') || st?.openJob
|
||||||
const tab = String(searchParams.get('tab') || '').toLowerCase()
|
const tab = String(searchParams.get('tab') || '').toLowerCase()
|
||||||
if (!st?.openCreate && !jobId) return
|
if (!st?.openCreate && !jobId) return
|
||||||
if (st?.openCreate) setCreating(true)
|
|
||||||
if (jobId) {
|
if (jobId) {
|
||||||
const job = jobs.find((j) => j.id === jobId)
|
navigate(`/job/${jobId}${tab === 'history' ? '?tab=history' : ''}`, { replace: true })
|
||||||
if (job) {
|
return
|
||||||
setViewing(job)
|
|
||||||
setViewingTab(tab === 'history' ? 'history' : 'details')
|
|
||||||
} else if (!jobsQuery.isSuccess) return
|
|
||||||
}
|
}
|
||||||
|
if (st?.openCreate) setCreating(true)
|
||||||
const next = new URLSearchParams(searchParams)
|
const next = new URLSearchParams(searchParams)
|
||||||
let queryChanged = false
|
let queryChanged = false
|
||||||
if (next.has('job')) {
|
if (next.has('job')) {
|
||||||
|
|
@ -143,15 +139,8 @@ export default function Jobs() {
|
||||||
queryChanged = true
|
queryChanged = true
|
||||||
}
|
}
|
||||||
if (queryChanged) setSearchParams(next, { replace: true })
|
if (queryChanged) setSearchParams(next, { replace: true })
|
||||||
if (st?.openJob || st?.openCreate) navigate('.', { replace: true, state: null })
|
if (st?.openCreate) navigate('.', { replace: true, state: null })
|
||||||
}, [location.state, searchParams, jobs, jobsQuery.isSuccess, navigate, setSearchParams])
|
}, [location.state, searchParams, navigate, setSearchParams])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!viewing) return
|
|
||||||
const fresh = jobs.find((j) => j.id === viewing.id)
|
|
||||||
if (fresh) setViewing(fresh)
|
|
||||||
else if (jobsQuery.isSuccess) setViewing(null)
|
|
||||||
}, [jobs]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const createJob = useMutation({
|
const createJob = useMutation({
|
||||||
mutationFn: async ({ payload, imageFile }) => {
|
mutationFn: async ({ payload, imageFile }) => {
|
||||||
|
|
@ -210,17 +199,6 @@ export default function Jobs() {
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
|
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteJob = useMutation({
|
|
||||||
mutationFn: (id) => jobsApi.remove(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
|
||||||
setViewing(null)
|
|
||||||
toast('Job deleted', 'success')
|
|
||||||
},
|
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the job'), 'error'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const departmentOptions = useMemo(
|
const departmentOptions = useMemo(
|
||||||
() => [...new Set(jobs.map(deptValue).filter(Boolean))].sort(),
|
() => [...new Set(jobs.map(deptValue).filter(Boolean))].sort(),
|
||||||
[jobs],
|
[jobs],
|
||||||
|
|
@ -292,7 +270,7 @@ export default function Jobs() {
|
||||||
key: '_a', label: 'Actions', align: 'right',
|
key: '_a', label: 'Actions', align: 'right',
|
||||||
render: (j) => (
|
render: (j) => (
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
<button className="act-btn" data-tip="View" aria-label="View job" onClick={(e) => { e.stopPropagation(); setViewing(j) }}><Icon name="eye" /></button>
|
<button className="act-btn" data-tip="View" aria-label="View job" onClick={(e) => { e.stopPropagation(); openJob(j.id) }}><Icon name="eye" /></button>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={(e) => { e.stopPropagation(); setEditing(j) }}><Icon name="edit" /></button>
|
<button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={(e) => { e.stopPropagation(); setEditing(j) }}><Icon name="edit" /></button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -381,36 +359,15 @@ export default function Jobs() {
|
||||||
rows={rows}
|
rows={rows}
|
||||||
pageSize={50}
|
pageSize={50}
|
||||||
empty="No requisitions match these filters."
|
empty="No requisitions match these filters."
|
||||||
onRowClick={(j) => setViewing(j)}
|
onRowClick={(j) => openJob(j.id)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewing && (
|
|
||||||
<JobDetail
|
|
||||||
key={viewing.id}
|
|
||||||
job={viewing}
|
|
||||||
initialTab={viewingTab}
|
|
||||||
canEdit={canEdit}
|
|
||||||
canDelete={canDelete}
|
|
||||||
statusBusy={setJobStatus.isPending}
|
|
||||||
deleteBusy={deleteJob.isPending}
|
|
||||||
onClose={() => { setViewing(null); setViewingTab('details') }}
|
|
||||||
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
|
|
||||||
onEdit={() => { setEditing(viewing); setViewing(null) }}
|
|
||||||
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
|
|
||||||
statusLabels={statusLabels}
|
|
||||||
onDelete={() => {
|
|
||||||
if (window.confirm(`Delete “${viewing.title}”?`)) deleteJob.mutate(viewing.id)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
<EditJobForm
|
<EditJobForm
|
||||||
job={editing}
|
job={editing}
|
||||||
departmentOptions={departmentOptions}
|
|
||||||
busy={updateJob.isPending}
|
busy={updateJob.isPending}
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditing(null)}
|
||||||
onSubmit={(body) => updateJob.mutate({ id: editing.id, body })}
|
onSubmit={(body) => updateJob.mutate({ id: editing.id, body })}
|
||||||
|
|
@ -419,7 +376,6 @@ export default function Jobs() {
|
||||||
|
|
||||||
{creating && (
|
{creating && (
|
||||||
<JobForm
|
<JobForm
|
||||||
departmentOptions={departmentOptions}
|
|
||||||
busy={createJob.isPending}
|
busy={createJob.isPending}
|
||||||
onClose={() => setCreating(false)}
|
onClose={() => setCreating(false)}
|
||||||
onSubmit={(payload, imageFile) => createJob.mutate({ payload, imageFile })}
|
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,
|
fontSize: 12, color: 'var(--text-3)', fontWeight: 600,
|
||||||
textTransform: 'uppercase', marginBottom: 6,
|
textTransform: 'uppercase', marginBottom: 6,
|
||||||
}
|
}
|
||||||
|
|
@ -442,91 +398,6 @@ function fmtWhen(value) {
|
||||||
return fmtDateTime(value) || null
|
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) {
|
function sameIdList(a, b) {
|
||||||
const x = [...(a || [])].map(String)
|
const x = [...(a || [])].map(String)
|
||||||
const y = [...(b || [])].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) {
|
function useRequisitionPicker(initialPicked = null, jobPostId = null) {
|
||||||
const [reqQ, setReqQ] = useState('')
|
const [reqQ, setReqQ] = useState('')
|
||||||
const [debouncedReqQ, setDebouncedReqQ] = useState('')
|
const [debouncedReqQ, setDebouncedReqQ] = useState('')
|
||||||
|
|
@ -693,17 +607,43 @@ function useRequisitionPicker(initialPicked = null, jobPostId = null) {
|
||||||
return { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq }
|
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 managersQuery = useManagerDirectory()
|
||||||
const recruitersQuery = useRecruiterDirectory()
|
const recruitersQuery = useRecruiterDirectory()
|
||||||
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker()
|
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker()
|
||||||
|
const dept = useDepartmentPicker()
|
||||||
|
|
||||||
const form = useFormState({
|
const form = useFormState({
|
||||||
hiring_manager_id: '',
|
hiring_manager_id: '',
|
||||||
current_recruiter_ids: [],
|
current_recruiter_ids: [],
|
||||||
requisition_id: '',
|
requisition_id: '',
|
||||||
title: '',
|
title: '',
|
||||||
department: '',
|
department_id: '',
|
||||||
location: '',
|
location: '',
|
||||||
employment_type: empTypes[0] || 'Full-time',
|
employment_type: empTypes[0] || 'Full-time',
|
||||||
vacancies: '1',
|
vacancies: '1',
|
||||||
|
|
@ -778,7 +718,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||||
// The cover image is uploaded separately right after the row exists.
|
// The cover image is uploaded separately right after the row exists.
|
||||||
onSubmit({
|
onSubmit({
|
||||||
title: v.title.trim(),
|
title: v.title.trim(),
|
||||||
department: v.department.trim() || null,
|
department_id: v.department_id || null,
|
||||||
location: v.location.trim() || null,
|
location: v.location.trim() || null,
|
||||||
employment_type: v.employment_type || null,
|
employment_type: v.employment_type || null,
|
||||||
vacancies,
|
vacancies,
|
||||||
|
|
@ -802,7 +742,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||||
// field itself and empty values before building the prompt.
|
// field itself and empty values before building the prompt.
|
||||||
const assistContext = () => ({
|
const assistContext = () => ({
|
||||||
title: form.values.title,
|
title: form.values.title,
|
||||||
department: form.values.department,
|
department: dept.pickedDept?.name || '',
|
||||||
location: form.values.location,
|
location: form.values.location,
|
||||||
employment_type: form.values.employment_type,
|
employment_type: form.values.employment_type,
|
||||||
experience_min: form.values.experience_min,
|
experience_min: form.values.experience_min,
|
||||||
|
|
@ -856,7 +796,9 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||||
if (opt) {
|
if (opt) {
|
||||||
setPickedReq(opt)
|
setPickedReq(opt)
|
||||||
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
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}
|
onQueryChange={setReqQ}
|
||||||
|
|
@ -881,7 +823,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
<label>Hiring manager</label>
|
||||||
<SearchSelect
|
<SearchSelect
|
||||||
options={managersQuery.data ?? []}
|
options={managersQuery.data ?? []}
|
||||||
value={form.values.hiring_manager_id}
|
value={form.values.hiring_manager_id}
|
||||||
|
|
@ -914,16 +856,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<div className="field-label-row">
|
<div className="field-label-row">
|
||||||
<label>Department</label>
|
<label>Department</label>
|
||||||
{assist('department')}
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<DepartmentSearchSelect dept={dept} value={form.values.department_id} onChange={(id) => form.setField('department_id', id)} disabled={busy} />
|
||||||
{...field('department')}
|
|
||||||
list="job-department-options"
|
|
||||||
placeholder="e.g. Engineering"
|
|
||||||
/>
|
|
||||||
<datalist id="job-department-options">
|
|
||||||
{departmentOptions.map((d) => <option key={d} value={d} />)}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<div className="field-label-row">
|
<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 managersQuery = useManagerDirectory()
|
||||||
const recruitersQuery = useRecruiterDirectory()
|
const recruitersQuery = useRecruiterDirectory()
|
||||||
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
|
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
|
||||||
|
|
@ -1073,10 +1007,11 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
: null,
|
: null,
|
||||||
j.id,
|
j.id,
|
||||||
)
|
)
|
||||||
|
const dept = useDepartmentPicker(j.departmentId ? { id: j.departmentId, name: j.department || 'Current department' } : null)
|
||||||
const form = useFormState({
|
const form = useFormState({
|
||||||
requisition_id: j.requisitionId || '',
|
requisition_id: j.requisitionId || '',
|
||||||
title: j.title || '',
|
title: j.title || '',
|
||||||
department: j.department || '',
|
department_id: j.departmentId || '',
|
||||||
location: j.location || '',
|
location: j.location || '',
|
||||||
employment_type: j.type || '',
|
employment_type: j.type || '',
|
||||||
vacancies: j.vacancies != null ? String(j.vacancies) : '1',
|
vacancies: j.vacancies != null ? String(j.vacancies) : '1',
|
||||||
|
|
@ -1089,7 +1024,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
|
|
||||||
const assistContext = () => ({
|
const assistContext = () => ({
|
||||||
title: form.values.title,
|
title: form.values.title,
|
||||||
department: form.values.department,
|
department: dept.pickedDept?.name || '',
|
||||||
location: form.values.location,
|
location: form.values.location,
|
||||||
employment_type: form.values.employment_type,
|
employment_type: form.values.employment_type,
|
||||||
experience_min: form.values.experience_min,
|
experience_min: form.values.experience_min,
|
||||||
|
|
@ -1118,7 +1053,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
if (Object.keys(errors).length) return
|
if (Object.keys(errors).length) return
|
||||||
onSubmit({
|
onSubmit({
|
||||||
title,
|
title,
|
||||||
department: form.values.department.trim() || null,
|
department_id: form.values.department_id || null,
|
||||||
location: form.values.location.trim() || null,
|
location: form.values.location.trim() || null,
|
||||||
employment_type: form.values.employment_type || null,
|
employment_type: form.values.employment_type || null,
|
||||||
vacancies: Number(form.values.vacancies) || 1,
|
vacancies: Number(form.values.vacancies) || 1,
|
||||||
|
|
@ -1163,7 +1098,9 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
if (opt) {
|
if (opt) {
|
||||||
setPickedReq(opt)
|
setPickedReq(opt)
|
||||||
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
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}
|
onQueryChange={setReqQ}
|
||||||
|
|
@ -1186,7 +1123,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
<FieldError>{form.errors.title}</FieldError>
|
<FieldError>{form.errors.title}</FieldError>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
<label>Hiring manager</label>
|
||||||
<SearchSelect
|
<SearchSelect
|
||||||
options={managersQuery.data ?? []}
|
options={managersQuery.data ?? []}
|
||||||
value={form.values.hiring_manager_id}
|
value={form.values.hiring_manager_id}
|
||||||
|
|
@ -1212,10 +1149,8 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<div className="field-label-row">
|
<div className="field-label-row">
|
||||||
<label>Department</label>
|
<label>Department</label>
|
||||||
{assist('department')}
|
|
||||||
</div>
|
</div>
|
||||||
<input list="edit-job-depts" value={form.values.department} onChange={(e) => form.setField('department', e.target.value)} disabled={busy} />
|
<DepartmentSearchSelect dept={dept} value={form.values.department_id} onChange={(id) => form.setField('department_id', id)} disabled={busy} />
|
||||||
<datalist id="edit-job-depts">{departmentOptions.map((d) => <option key={d} value={d} />)}</datalist>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<div className="field-label-row">
|
<div className="field-label-row">
|
||||||
|
|
@ -1260,7 +1195,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
* Hiring-manager + recruiter pointers on one requisition.
|
* Hiring-manager + recruiter pointers on one requisition.
|
||||||
* Writes go through PATCH /jobs/update; job_assignments keeps the interval log.
|
* Writes go through PATCH /jobs/update; job_assignments keeps the interval log.
|
||||||
*/
|
*/
|
||||||
function JobOwnership({ job, canEdit }) {
|
export function JobOwnership({ job, canEdit }) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const managersQuery = useManagerDirectory()
|
const managersQuery = useManagerDirectory()
|
||||||
|
|
@ -1284,7 +1219,7 @@ function JobOwnership({ job, canEdit }) {
|
||||||
<div style={SECTION_LABEL}>Ownership</div>
|
<div style={SECTION_LABEL}>Ownership</div>
|
||||||
<div className="form-grid" style={{ marginBottom: 12 }}>
|
<div className="form-grid" style={{ marginBottom: 12 }}>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
<label>Hiring manager</label>
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<SearchSelect
|
<SearchSelect
|
||||||
options={managersQuery.data ?? []}
|
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 assignments = historyQuery.data ?? []
|
||||||
const statusRows = statusQuery.data ?? []
|
const statusRows = statusQuery.data ?? []
|
||||||
const offerRows = offersQuery?.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
|
/* Cover image, when the post has one — fetched with the bearer token into an
|
||||||
object URL, because a bare <img src> cannot carry auth headers. null (404)
|
object URL, because a bare <img src> cannot carry auth headers. null (404)
|
||||||
simply renders nothing. */
|
simply renders nothing. */
|
||||||
function JobCover({ jobId }) {
|
export function JobCover({ jobId }) {
|
||||||
const [url, setUrl] = useState(null)
|
const [url, setUrl] = useState(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true
|
let alive = true
|
||||||
|
|
@ -1476,136 +1411,3 @@ function JobCover({ jobId }) {
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
return <img src={url} alt="Job cover" className="job-cover" />
|
return <img src={url} alt="Job cover" className="job-cover" />
|
||||||
}
|
}
|
||||||
|
|
||||||
function JobDetail({
|
|
||||||
job: j, initialTab = 'details', canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
|
|
||||||
statusLabels = jobsApi.JOB_STATUSES,
|
|
||||||
}) {
|
|
||||||
const [tab, setTab] = useState(initialTab === 'history' ? 'history' : 'details')
|
|
||||||
const historyQuery = useQuery({
|
|
||||||
queryKey: qk.assignments.job(j.id),
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await assignmentsApi.listJob(j.id, { currentOnly: false })
|
|
||||||
const rows = Array.isArray(res?.data) ? res.data : []
|
|
||||||
return rows.map((r) => assignmentsApi.toAssignmentView(r))
|
|
||||||
},
|
|
||||||
enabled: Boolean(j.id),
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
const statusQuery = useQuery({
|
|
||||||
queryKey: qk.jobs.statusHistory(j.id),
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await jobsApi.listStatusHistory(j.id)
|
|
||||||
return Array.isArray(res?.data) ? res.data : []
|
|
||||||
},
|
|
||||||
enabled: Boolean(j.id),
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
const offersQuery = useQuery({
|
|
||||||
queryKey: qk.offers.list({ jobPostId: j.id, top: 200 }),
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await offersApi.list({ jobPostId: j.id, top: 200 })
|
|
||||||
return Array.isArray(res?.data) ? res.data : []
|
|
||||||
},
|
|
||||||
enabled: Boolean(j.id),
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0) + (offersQuery.data?.length ?? 0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title="Job Details"
|
|
||||||
subtitle={deptValue(j) || undefined}
|
|
||||||
size="modal-lg"
|
|
||||||
onClose={onClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
{canDelete && (
|
|
||||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)', marginRight: 'auto' }} onClick={onDelete} disabled={deleteBusy}>
|
|
||||||
<Icon name="trash" /> {deleteBusy ? 'Deleting…' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
|
||||||
{canEdit && (
|
|
||||||
<button className="btn btn-secondary" onClick={onEdit}><Icon name="edit" /> Edit</button>
|
|
||||||
)}
|
|
||||||
<button className="btn btn-secondary" onClick={onPublish}><Icon name="send" /> Publish</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<JobCover jobId={j.id} />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-16 mb-18">
|
|
||||||
<span className="kpi-icn i-indigo" style={{ width: 52, height: 52, borderRadius: 14 }}>
|
|
||||||
<Icon name="briefcase" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 19, fontWeight: 700 }}>{j.title}</div>
|
|
||||||
<div className="text-muted">{[deptValue(j), j.location].filter(Boolean).join(' · ') || '—'}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginLeft: 'auto' }}>
|
|
||||||
{canEdit ? (
|
|
||||||
<select
|
|
||||||
className="select"
|
|
||||||
value={j.status}
|
|
||||||
disabled={statusBusy}
|
|
||||||
onChange={(e) => onStatus(e.target.value)}
|
|
||||||
>
|
|
||||||
{statusLabels.map((s) => <option key={s}>{s}</option>)}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<Badge>{j.status}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
value={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
tabs={[
|
|
||||||
{ key: 'details', label: 'Details' },
|
|
||||||
{ key: 'history', label: 'History', count: historyCount || undefined },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{tab === 'details' && (
|
|
||||||
<>
|
|
||||||
<div className="info-grid mb-18">
|
|
||||||
<div className="info-item"><div className="il">Department</div><div className="iv">{deptLabel(j)}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || '—'}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? '—'}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Assigned Recruiters</div><div className="iv">{j.recruiter || '—'}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<JobOwnership job={j} canEdit={canEdit} />
|
|
||||||
|
|
||||||
{j.description && (
|
|
||||||
<>
|
|
||||||
<div className="divider" />
|
|
||||||
<div className="mb-16">
|
|
||||||
<div style={SECTION_LABEL}>Description</div>
|
|
||||||
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!!(j.skills && j.skills.length) && (
|
|
||||||
<div>
|
|
||||||
<div style={SECTION_LABEL}>Required Skills</div>
|
|
||||||
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} offersQuery={offersQuery} />}
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,4 @@
|
||||||
/* ============================================================
|
|
||||||
Recruiter Hub — live, by pointing every analytics endpoint at one recruiter.
|
|
||||||
|
|
||||||
The trick that makes this screen real: /analytics/kpis, /hiring-trend and
|
|
||||||
/funnel all take a `recruiter_id`, so selecting a recruiter re-scopes the
|
|
||||||
whole page server-side rather than filtering a client-side array. The
|
|
||||||
recruiter list itself is /analytics/recruiter-performance, which is also the
|
|
||||||
leaderboard.
|
|
||||||
|
|
||||||
TEN OF THE PROTOTYPE'S EIGHTEEN TILES ARE GONE. workload %, efficiency %, SLA
|
|
||||||
state, interview completion %, avg response time, TAT %, star rating, jobs
|
|
||||||
awaiting approval and jobs overdue have no column, no table and in most cases
|
|
||||||
no concept behind them — there is no approval workflow and no requisition
|
|
||||||
deadline in the schema. They were random numbers re-rolled on every render.
|
|
||||||
What replaced them is derived from real counts and labelled as such:
|
|
||||||
conversion rate is hires ÷ candidates, offer acceptance is accepted ÷ sent.
|
|
||||||
|
|
||||||
The workload heatmap survived because interviews are real: it buckets
|
|
||||||
/interview/fetch over the last five weeks by weekday. It is TEAM-WIDE, not
|
|
||||||
per recruiter — the interviews table has no recruiter column — and the card
|
|
||||||
says so rather than implying the selected person owns all of it.
|
|
||||||
|
|
||||||
Tasks belong here too: GET /tasks/fetch?assignee_id= the selected recruiter
|
|
||||||
is the worklist the prototype filed under Recruiter Hub. Completing a row
|
|
||||||
writes the same /tasks/update the Tasks screen uses, so the two stay in
|
|
||||||
sync. Hidden without tasks.view; the rest of the hub still loads.
|
|
||||||
|
|
||||||
Interviews Today / upcoming / the heatmap join interviews → job_posts via
|
|
||||||
COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id) and
|
|
||||||
filter on current_recruiter_id. The leaderboard ranks by completed
|
|
||||||
requisitions (requisition_status=completed), not inbox hires.
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import DataTable from '../ui/DataTable'
|
import DataTable from '../ui/DataTable'
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
|
import DepartmentSelect from '../ui/DepartmentSelect'
|
||||||
import PageHeader from '../ui/PageHeader'
|
import PageHeader from '../ui/PageHeader'
|
||||||
import { Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
|
import { Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
|
||||||
import { useToast } from '../ui/Toast'
|
import { useToast } from '../ui/Toast'
|
||||||
|
|
@ -269,7 +270,8 @@ export default function Requisitions() {
|
||||||
|
|
||||||
function blankForm() {
|
function blankForm() {
|
||||||
return {
|
return {
|
||||||
department: '',
|
department_id: '',
|
||||||
|
department_name: '',
|
||||||
title: '',
|
title: '',
|
||||||
date: toDateInput(new Date().toISOString()),
|
date: toDateInput(new Date().toISOString()),
|
||||||
date_needed: '',
|
date_needed: '',
|
||||||
|
|
@ -308,7 +310,8 @@ function fromRow(row) {
|
||||||
const rep = row.replacement_for || {}
|
const rep = row.replacement_for || {}
|
||||||
const ref = row.refferal_by || {}
|
const ref = row.refferal_by || {}
|
||||||
return {
|
return {
|
||||||
department: pos.department || '',
|
department_id: pos.department_id ? String(pos.department_id) : '',
|
||||||
|
department_name: pos.department || '',
|
||||||
title: pos.title || '',
|
title: pos.title || '',
|
||||||
date: toDateInput(pos.date),
|
date: toDateInput(pos.date),
|
||||||
date_needed: toDateInput(pos.date_needed),
|
date_needed: toDateInput(pos.date_needed),
|
||||||
|
|
@ -345,7 +348,8 @@ function fromRow(row) {
|
||||||
function toPayload(f) {
|
function toPayload(f) {
|
||||||
const body = {
|
const body = {
|
||||||
position: {
|
position: {
|
||||||
department: emptyToNull(f.department),
|
department_id: emptyToNull(f.department_id),
|
||||||
|
department: emptyToNull(f.department_name),
|
||||||
title: emptyToNull(f.title),
|
title: emptyToNull(f.title),
|
||||||
date: emptyToNull(f.date),
|
date: emptyToNull(f.date),
|
||||||
date_needed: emptyToNull(f.date_needed),
|
date_needed: emptyToNull(f.date_needed),
|
||||||
|
|
@ -453,7 +457,14 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>From (Dept.)</label>
|
<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>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>Job title <span className="req">*</span></label>
|
<label>Job title <span className="req">*</span></label>
|
||||||
|
|
@ -507,7 +518,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>
|
<label className="hf-check">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={fields.jd_available}
|
checked={fields.jd_available}
|
||||||
|
|
@ -642,7 +653,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="hf-sign">
|
<div className="hf-sign">
|
||||||
<span className="hf-sign-role">Approved by · Director HR</span>
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={fields.approved_by_hr}
|
checked={fields.approved_by_hr}
|
||||||
|
|
@ -658,7 +669,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="hf-sign">
|
<div className="hf-sign">
|
||||||
<span className="hf-sign-role">Approved by · VP</span>
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={fields.approved_by_vp}
|
checked={fields.approved_by_vp}
|
||||||
|
|
@ -674,7 +685,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="hf-sign">
|
<div className="hf-sign">
|
||||||
<span className="hf-sign-role">Approved by · SVP</span>
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={fields.approved_by_svp}
|
checked={fields.approved_by_svp}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
.form-section-title { margin: var(--space-5) 0 var(--space-1); grid-column: 1/-1; }
|
||||||
|
|
||||||
/* AI field assist (ui/AiFieldAssist.jsx) */
|
/* 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 { 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 { 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; }
|
.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-meta svg { width: 13px; height: 13px; }
|
||||||
.cand-company { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-3); }
|
.cand-company { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-3); }
|
||||||
|
|
||||||
|
/* Job profile page (JobProfile.jsx) — hero stats, ranked suggested-candidate
|
||||||
|
cards and the optional-skill highlight shared by cards and the Details tab. */
|
||||||
|
.cand-page-crumb a { color: var(--text-3); }
|
||||||
|
.cand-page-crumb a:hover { color: var(--text); }
|
||||||
|
.job-hero { flex-wrap: wrap; margin-bottom: 18px; }
|
||||||
|
.job-hero-icn { width: 60px; height: 60px; border-radius: 14px; flex: none; display: grid; place-items: center; background: var(--primary-soft); color: var(--primary); }
|
||||||
|
.job-hero-icn svg { width: 26px; height: 26px; }
|
||||||
|
.job-hero-id { min-width: 0; }
|
||||||
|
.job-hero .ph-name { overflow-wrap: anywhere; }
|
||||||
|
.job-hero-actions { margin-left: auto; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.hero-stat { text-align: center; min-width: 60px; }
|
||||||
|
.hero-stat .v { font-family: var(--font-display); font-size: 22px; font-weight: 600; line-height: 1.1; }
|
||||||
|
.hero-stat .l { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||||||
|
.cand-sub { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 16px 0 12px; font-size: 13px; color: var(--text-2); }
|
||||||
|
.auto-tag { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; padding: 2px 7px; border-radius: 6px; background: var(--primary-soft); color: var(--primary); }
|
||||||
|
.select.active-filter { border-color: var(--primary); color: var(--primary); font-weight: 600; }
|
||||||
|
.cand-card .card-body { position: relative; }
|
||||||
|
.cand-rank { position: absolute; top: 12px; left: 12px; width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-sunken); color: var(--text-2); font-size: 11px; font-weight: 700; }
|
||||||
|
.cand-card.ranked .cand-head { padding-left: 22px; }
|
||||||
|
.cand-chip.ok { background: var(--success-soft); color: var(--success); }
|
||||||
|
.cand-chip.opt { background: var(--purple-soft); color: var(--purple); }
|
||||||
|
.tag.tag-optional { display: inline-flex; align-items: center; gap: 4px; background: var(--purple-soft); color: var(--purple); }
|
||||||
|
.tag.tag-optional svg { width: 11px; height: 11px; }
|
||||||
|
|
||||||
/* Find Talent toolbar (Talent.jsx): the job picker takes the slack, the
|
/* Find Talent toolbar (Talent.jsx): the job picker takes the slack, the
|
||||||
location controls hold a readable fixed width. The widths live here, not
|
location controls hold a readable fixed width. The widths live here, not
|
||||||
inline, so the ≤640 block can stack everything full-width. */
|
inline, so the ≤640 block can stack everything full-width. */
|
||||||
|
|
@ -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 { 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-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; }
|
.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 */
|
/* Rating table: the paper grid — scale header, radio-dot cells, average foot */
|
||||||
.hf-rate { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.g-kpi-7 { grid-template-columns: 1fr; }
|
.g-kpi-7 { grid-template-columns: 1fr; }
|
||||||
.cand-page-actions { width: 100%; }
|
.cand-page-actions { width: 100%; }
|
||||||
|
.job-hero-actions { width: 100%; margin-left: 0; }
|
||||||
.cand-page-actions .btn { flex: 1 1 auto; justify-content: center; }
|
.cand-page-actions .btn { flex: 1 1 auto; justify-content: center; }
|
||||||
.hf-sign-grid { grid-template-columns: 1fr; }
|
.hf-sign-grid { grid-template-columns: 1fr; }
|
||||||
.hf-summary { grid-template-columns: repeat(2, 1fr); }
|
.hf-summary { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
|
@ -2289,3 +2318,35 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
@media (max-width: 400px) {
|
@media (max-width: 400px) {
|
||||||
.hf-summary { grid-template-columns: 1fr; }
|
.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); }
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ export default defineConfig({
|
||||||
// VITE_API_TARGET repoints the proxy when the API runs elsewhere
|
// VITE_API_TARGET repoints the proxy when the API runs elsewhere
|
||||||
// (e.g. 8001 locally because another service holds 8000).
|
// (e.g. 8001 locally because another service holds 8000).
|
||||||
proxy: {
|
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',
|
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
// Several API prefixes double as SPA routes (/jobs, /inbox, …).
|
// Several API prefixes double as SPA routes (/jobs, /inbox, …).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue