Compare commits
No commits in common. "dev_main" and "main" have entirely different histories.
|
|
@ -3,7 +3,7 @@ name: Deploy to S3
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev_main
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
run: |
|
||||
apt-get update -y
|
||||
apt-get install -y zip
|
||||
zip -r utopia-ai-hr-ats-portal-dev.zip . \
|
||||
zip -r utopia-ai-hr-ats-portal.zip . \
|
||||
-x ".git/*" \
|
||||
-x ".gitea/*" \
|
||||
-x ".gitignore/*" \
|
||||
|
|
@ -46,10 +46,7 @@ jobs:
|
|||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
echo "Uploading repo contents to S3..."
|
||||
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
|
||||
|
||||
|
||||
|
||||
aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,4 @@ frontend/dist/index.html
|
|||
frontend/dist/**
|
||||
nginx.conf
|
||||
smoke.test.mjs
|
||||
**.docx
|
||||
**.docs
|
||||
Annex**
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,835 +0,0 @@
|
|||
# RBAC Context Prompt — HR-ATS-Portal (TalentFlow)
|
||||
|
||||
> Paste this whole file as context for an engineer or LLM that must reproduce this system's
|
||||
> role-based access control exactly. Everything below is taken from the code on branch
|
||||
> `Department_Module`. File references point back to the source of truth.
|
||||
> Where the code has a quirk or gap, it is written down as-is under **Known behaviour to
|
||||
> reproduce (or consciously fix)**. Do not tidy these away silently.
|
||||
|
||||
---
|
||||
|
||||
## 0. Your task
|
||||
|
||||
You are implementing an access-control layer that must behave **identically** to the one
|
||||
described here. That means the same:
|
||||
|
||||
- data model (tags → bundles → roles → users),
|
||||
- permission vocabulary (136 `module.action` tags),
|
||||
- resolution algorithm (live, per request, deny-by-default),
|
||||
- enforcement order and HTTP status codes and error strings,
|
||||
- rules against privilege escalation,
|
||||
- row-level data scoping (who sees which jobs, candidates, offers, requisitions),
|
||||
- role-name-based business rules (tasks, assignments, hiring-manager portal),
|
||||
- frontend gating (routes, sidebar, buttons) and the Access Control matrix editor.
|
||||
|
||||
When this document and your instincts disagree, follow this document.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core concepts in one paragraph
|
||||
|
||||
A **permission tag** is one atomic `module.action` string, such as `candidates.view`. A
|
||||
**permission bundle** (table `permissions`) is a named JSONB array of tag ids. A **role** is a
|
||||
named JSONB array of bundle ids. A **user** has zero or one role (`users.role_id`, nullable).
|
||||
On every authenticated request the server walks role → bundles → tags and builds a flat list of
|
||||
tag names. Route guards check that list. Service code then narrows which rows are visible
|
||||
using a few helper predicates, some of which look at tags and some at the role name. Tags
|
||||
never go into the JWT, so a permission change takes effect on the server on the very next
|
||||
request.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data model
|
||||
|
||||
PostgreSQL, schema `app`. SQLModel/SQLAlchemy async. Every table soft-deletes
|
||||
(`is_deleted`) and has an `is_active` flag.
|
||||
|
||||
### 2.1 `permission_tags` — [backend/role/models.py](backend/role/models.py)
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| `id` | int PK | seed order matters: it sets matrix ordering and resolution ordering |
|
||||
| `tag_name` | varchar(64) unique, indexed | `"{module}.{action}"` |
|
||||
| `module` | varchar(32) indexed | |
|
||||
| `action` | varchar(32) | |
|
||||
| `description` | text null | |
|
||||
| `is_active` / `is_deleted` | bool | |
|
||||
| `created_at` / `updated_at` | timestamptz | |
|
||||
|
||||
Unique constraint `uq_permission_tags_module_action` on (`module`, `action`).
|
||||
|
||||
### 2.2 `permissions` (bundles)
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| `id` | int PK | |
|
||||
| `name` | varchar(64) unique | |
|
||||
| `description` | text null | |
|
||||
| `permission_tags` | JSONB int[] | ids from `permission_tags.id`, **no FK** |
|
||||
| `is_system` | bool | system bundles cannot be renamed |
|
||||
| `is_active` / `is_deleted` / timestamps | | |
|
||||
|
||||
### 2.3 `roles`
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| `id` | int PK | |
|
||||
| `role_name` | varchar(64) unique | free text (initial Alembic revision used an enum; the model is a varchar) |
|
||||
| `description` | text null | |
|
||||
| `permissions` | JSONB int[] | ids from `permissions.id`, **no FK** |
|
||||
| `is_system` | bool | system roles cannot be renamed or deleted |
|
||||
| `is_active` / `is_deleted` / timestamps | | |
|
||||
|
||||
### 2.4 `users` — [backend/users/models.py](backend/users/models.py)
|
||||
|
||||
Only the RBAC-relevant columns:
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| `id` | uuid PK | the JWT `sub` |
|
||||
| `email` | unique | |
|
||||
| `role_id` | int FK → `roles.id`, **nullable** | `role` relationship is `lazy="selectin"` |
|
||||
| `is_active` | bool, default **false** | set true by email confirmation |
|
||||
| `is_approved` | bool, default **false** | set true by an admin (or at admin creation) |
|
||||
| `is_deleted` | bool | |
|
||||
|
||||
### 2.5 System role keys — `EnumRoles`
|
||||
|
||||
```
|
||||
system_administrator hr_administrator recruiter hiring_manager
|
||||
department_head interviewer ceo candidate
|
||||
```
|
||||
|
||||
Seed ids follow that order: 1 system_administrator, 2 hr_administrator, 3 recruiter,
|
||||
4 hiring_manager, … , 8 candidate. **Some code hardcodes ids 4 and 8** (see §12).
|
||||
|
||||
Migration `026` soft-deletes `hr_administrator`, `interviewer` and `ceo` when no live user
|
||||
holds them. The organisation runs four staff roles (`system_administrator`, `recruiter`,
|
||||
`hiring_manager`, `department_head`) plus `candidate`. Migration `027` moves members of a
|
||||
hand-made role named `Manager` onto `hiring_manager` and soft-deletes it. Code still accepts the
|
||||
names `manager` and `admin` (see §6).
|
||||
|
||||
---
|
||||
|
||||
## 3. Permission vocabulary — 17 modules × 8 actions = 136 tags
|
||||
|
||||
Source: `PermissionModule`, `PermissionAction`, `PermissionTag` in
|
||||
[backend/users/permissions.py](backend/users/permissions.py).
|
||||
|
||||
**Modules:** `dashboard, inbox, jobs, candidates, pipeline, department, interviews,
|
||||
assessments, offers, reports, analytics, job_board, settings, rbac_users, tasks, talent,
|
||||
requisitions`
|
||||
|
||||
**Actions:** `view, create, edit, delete, approve, export, manage, configure`
|
||||
|
||||
Rules:
|
||||
|
||||
1. `PermissionTag` is a `str` Enum listing every combination explicitly. At import time,
|
||||
`_assert_vocabulary_complete()` raises `RuntimeError("PermissionTag vocabulary drift:
|
||||
missing=[...] extra=[...]")` unless the enum equals the full modules × actions cross-product.
|
||||
The server will not boot with a partial vocabulary.
|
||||
2. Always serialise with `.value`. `f"{PermissionTag.X}"` renders the enum repr.
|
||||
3. DB rows are seeded idempotently (`ON CONFLICT (tag_name) DO NOTHING`) by manual SQL
|
||||
migrations: `001` (first 13 modules = 104 tags), `004` tasks, `007` talent,
|
||||
`019` requisitions, `038` department. Comments in the code that say "104" or "120" tags are
|
||||
stale. The real total is 136.
|
||||
4. Two actions carry special meaning beyond "may use the screen":
|
||||
- `*.manage` on `requisitions`, `candidates` and `offers` **removes row scoping**. See §6.
|
||||
- `requisitions.configure` is an **opt-in to scoping**, not a screen permission. See §6.
|
||||
|
||||
---
|
||||
|
||||
## 4. Resolution algorithm — `Roles.resolve_tags(session, role)`
|
||||
|
||||
```
|
||||
if role is None or not role.is_active or role.is_deleted: return ()
|
||||
perm_ids = role.permissions
|
||||
if not perm_ids or not a list: return ()
|
||||
bundles = SELECT permissions WHERE id IN perm_ids AND is_active AND NOT is_deleted
|
||||
tag_ids = concat(bundle.permission_tags for each bundle whose permission_tags is a non-empty list)
|
||||
if not tag_ids: return ()
|
||||
tags = SELECT permission_tags WHERE id IN tag_ids AND is_active AND NOT is_deleted
|
||||
sort tags by (id, tag_name); de-duplicate by tag_name keeping first
|
||||
return tuple(tag_name ...)
|
||||
```
|
||||
|
||||
Properties you must preserve:
|
||||
|
||||
- **Deny by default, never error.** Dangling ids, inactive or deleted bundles, and inactive or
|
||||
deleted tags simply add nothing.
|
||||
- **Union.** Tags from every attached bundle are merged. There are no negative grants.
|
||||
- **Live.** It runs on every request inside `get_current_user`. Nothing is cached server-side
|
||||
and nothing is put in the token.
|
||||
- **Stable order.** The output is ordered by tag id, which is seed order.
|
||||
|
||||
---
|
||||
|
||||
## 5. Authentication and enforcement pipeline
|
||||
|
||||
### 5.1 Tokens — [backend/users/plugins.py](backend/users/plugins.py)
|
||||
|
||||
PyJWT HS256. Every token carries `sub`, `type`, `iat`, `exp`, `jti`.
|
||||
`decode_token(token, expected_type=...)` rejects a token whose `type` does not match.
|
||||
|
||||
| type | default lifetime | extra claims |
|
||||
|---|---|---|
|
||||
| `access` | 30 min | `email`, `role_id` |
|
||||
| `refresh` | 7 days | — |
|
||||
| `reset` | 10 min | `crid` |
|
||||
|
||||
The token's `role_id` is informational only. Authorization always reloads the user from the DB.
|
||||
There is no logout endpoint, no denylist and no `jti` tracking.
|
||||
|
||||
Login, signup, refresh and `/users/create` return:
|
||||
`{access_token, refresh_token, token_type:"bearer", expires_in, data: <user without permissions>, status_code}`.
|
||||
|
||||
### 5.2 `get_current_user` (dependency alias `CurrentUser`)
|
||||
|
||||
The checks run in this order:
|
||||
|
||||
1. HTTP Bearer header is missing → FastAPI `HTTPBearer` returns **401** `"Not authenticated"` (FastAPI 0.136.1 behaviour).
|
||||
2. Decode fails or type is not `access` → **401** `"Could not validate credentials"` with `WWW-Authenticate: Bearer`.
|
||||
3. Load the user by `sub` via `Users.get_user_by_id`. That query **excludes `role_id = 8` (candidate)**.
|
||||
If the user is missing, `is_deleted`, or `!is_active` → **401** `"User is inactive or does not exist"`.
|
||||
4. `!is_approved` → **403** `"Your Approval is at Pending"`.
|
||||
5. `permissions = resolve_tags(user.role)`.
|
||||
6. Return `serialize_user(user, with_permissions=True, permissions=...)`:
|
||||
|
||||
```json
|
||||
{ "id": "uuid", "name": "...", "email": "...", "role_id": 3, "role_name": "recruiter",
|
||||
"role_description": "...", "linkedin_url": null, "is_active": true, "is_approved": true,
|
||||
"is_deleted": false, "created_at": "...", "updated_at": "...",
|
||||
"permissions": ["dashboard.view", "..."] }
|
||||
```
|
||||
|
||||
`GET /users/me` returns exactly this and requires only `CurrentUser`, with no tag. That way a user
|
||||
with no role can still discover their state.
|
||||
|
||||
### 5.3 `require_permission(*tags, require_all=True)`
|
||||
|
||||
A FastAPI dependency factory that runs after `get_current_user`:
|
||||
|
||||
1. `current_user.role_id is None` → **403** `"User has no role assigned"`. This check runs before any tag check.
|
||||
2. `has_permission(granted, *tags, require_all)`:
|
||||
- `require_all=True` → required ⊆ granted (AND)
|
||||
- `require_all=False` → required ∩ granted ≠ ∅ (OR)
|
||||
3. On failure → **403** with one of these exact details:
|
||||
- one tag, AND: `"Missing required permission: candidates.view"`
|
||||
- several tags, AND: `"Missing required permissions: a, b"`
|
||||
- OR: `"Missing any of required permissions: a, b"`
|
||||
4. On success it returns `current_user`, and handlers use it as `current_user: dict`.
|
||||
|
||||
A user whose role is soft-deleted or inactive still has a `role_id`. They pass step 1, resolve
|
||||
to zero tags, and fail step 3.
|
||||
|
||||
### 5.4 Login and account-state rules — [backend/users/views.py](backend/users/views.py)
|
||||
|
||||
- `authenticate_user`: the email lookup excludes role 8. A bad email or password returns **401**
|
||||
`"Incorrect email or password"`. Then `is_deleted` → 401 `"User is inactive"`, then
|
||||
`!is_active` → 401 `"Please confirm your email address to activate your account"`, then
|
||||
`!is_approved` → 403 `"Your Approval is at Pending"`.
|
||||
- `refresh_access_token`: runs the same active, deleted and approved checks, with 401
|
||||
`"Invalid or expired refresh token"` on a decode failure.
|
||||
- **Signup** (`POST /users/signup`, public): hardcodes `role_id = 4` and `is_approved = false`,
|
||||
lands with `is_active = false`, and emails a confirmation link that sets `is_active`.
|
||||
An admin must then approve the account.
|
||||
- **Admin create** (`POST /users/create`, needs `rbac_users.create`): sets `is_approved = true`.
|
||||
`is_active` comes from the payload (default true). The escalation check in §5.5 applies.
|
||||
- **Approval queue**: `GET /users/pending-approvals` (needs `settings.view`) lists users who are
|
||||
active, not approved, not deleted and not role 8. `PUT /users/approve?record_id=` (needs
|
||||
`rbac_users.edit`) returns 400 when the user is not active: `"User must confirm their email
|
||||
before approval"`.
|
||||
- **Candidate accounts (role 8)** cannot log in or resolve through `get_current_user`. They are
|
||||
data records for applicants, not portal users.
|
||||
|
||||
### 5.5 Anti-escalation on role assignment — `User._check_role_assignment`
|
||||
|
||||
Used by `POST /users/create`, `PUT /users/assign-role` and `PUT /users/remove-role`. Both
|
||||
assign and remove are also route-guarded by `rbac_users.edit`.
|
||||
|
||||
```
|
||||
if new_role_id == existing_role_id: return # no-op, no checks
|
||||
if 'rbac_users.manage' not in caller.perms: 403 "Assigning a role requires rbac_users.manage"
|
||||
if new_role_id is None: return # removal needs only manage
|
||||
role = roles[new_role_id]
|
||||
if role missing or is_deleted: 404 "Role not found"
|
||||
if not role.is_active: 400 "Role is not active"
|
||||
missing = resolve_tags(role) - caller.perms
|
||||
if missing: 403 "Cannot assign a role with permissions you do not hold: a, b"
|
||||
```
|
||||
|
||||
So a caller can only hand out a role whose effective tags are a subset of their own.
|
||||
|
||||
---
|
||||
|
||||
## 6. Row-level scoping (data visibility on top of tags)
|
||||
|
||||
Tags decide **whether** a user may call an endpoint. These predicates decide **which rows**
|
||||
they get back. They are pure functions of the `current_user` dict.
|
||||
Backend: [backend/users/permissions.py](backend/users/permissions.py). The frontend mirror is
|
||||
in [frontend/src/auth/permissions.js](frontend/src/auth/permissions.js) and must stay
|
||||
byte-for-byte equivalent in logic.
|
||||
|
||||
```python
|
||||
def is_hiring_manager(u): # "hiring-manager portal" user
|
||||
return lower(strip(u.role_name)) in {"hiring_manager", "manager"}
|
||||
|
||||
def is_admin(u): # org-wide staff
|
||||
return lower(strip(u.role_name)) in {"system_administrator", "hr_administrator", "admin"} \
|
||||
or "requisitions.manage" in u.permissions
|
||||
|
||||
def sees_all_candidates(u):
|
||||
return is_admin(u) or "candidates.manage" in u.permissions
|
||||
|
||||
def sees_all_offers(u):
|
||||
return is_admin(u) or "offers.manage" in u.permissions
|
||||
|
||||
def scopes_to_own_requisitions(u): # evaluate in this exact order
|
||||
if is_hiring_manager(u): return True # wins even over admin tags
|
||||
if is_admin(u) or sees_all_candidates(u): return False
|
||||
return "requisitions.configure" in u.permissions
|
||||
```
|
||||
|
||||
Design rule stated in the code: **custom roles must be able to opt in through Access Control
|
||||
tags. Never key scoping off `role_id`.** Role-name checks exist only for the seeded
|
||||
hiring-manager and admin identities.
|
||||
|
||||
### 6.1 Job ownership sets — [backend/job/job_post/models.py](backend/job/job_post/models.py)
|
||||
|
||||
- `JobPosts.ids_for_manager(user_id)` returns non-deleted job posts where
|
||||
`hiring_manager_id = user`, **unioned with** jobs whose `requisition_id` points at a
|
||||
non-deleted requisition with `created_by = user`.
|
||||
- `JobPosts.ids_for_creator(user_id, created_by=False)`:
|
||||
- `created_by=True` returns jobs with `created_by = user`.
|
||||
- Otherwise it returns jobs where the user is in `current_recruiter_ids` or is
|
||||
`current_recruiter_id`, **or** (the job has no recruiters **and** `created_by = user`).
|
||||
|
||||
### 6.2 `owned_job_ids_for_candidate_scope(session, user, created_by=False)` — [backend/job/candidate/views.py](backend/job/candidate/views.py)
|
||||
|
||||
```
|
||||
if scopes_to_own_requisitions(user): return ids_for_manager(user.id)
|
||||
if sees_all_candidates(user): return None # None = unscoped
|
||||
return ids_for_creator(user.id, created_by)
|
||||
```
|
||||
|
||||
`job_post_ids_for_candidate_list` intersects a caller-requested job filter with that set.
|
||||
`None` means unscoped and `[]` means nothing is visible.
|
||||
|
||||
### 6.3 `assert_manager_candidate_access(session, user, user_id|job_post_id|inbox_id|manual_id)`
|
||||
|
||||
```
|
||||
if sees_all_candidates(user) and not scopes_to_own_requisitions(user): allow
|
||||
owned = owned_job_ids_for_candidate_scope(...) or []
|
||||
if not owned: 403 <scope detail>
|
||||
resolve job_id (and candidate uid) from inbox_id / manual_id when not given
|
||||
if job_id is None and uid is not None:
|
||||
allow if any job the candidate is assigned to ∈ owned, else 403
|
||||
if job_id not in owned: 403 <scope detail>
|
||||
```
|
||||
|
||||
The scope detail is `MANAGER_SCOPE_DETAIL` when requisition-scoped and `CREATOR_SCOPE_DETAIL` otherwise.
|
||||
|
||||
### 6.4 Where scoping is applied
|
||||
|
||||
| Area | Rule |
|
||||
|---|---|
|
||||
| Candidates list / detail / applications / notes / forms | `owned_job_ids_for_candidate_scope` + `assert_manager_candidate_access` |
|
||||
| `GET /candidate/fetch/users` and `/count` | Hiring-manager users get **403** `"Hiring managers can only list candidates on their requisitions"` |
|
||||
| Candidate detail without `user_id` | Hiring manager → 403 `MANAGER_SCOPE_DETAIL` |
|
||||
| Job posts list (`fetch_job_posts`) | If `scopes_to_own_requisitions`, restrict to `ids_for_manager`. Requested ids outside that set are dropped, and an empty set returns `[]` |
|
||||
| Offers (candidate picker, create, sent) | `sees_all_offers` → unscoped. Otherwise use owned job ids, and an out-of-scope job returns 403 `"This offer is outside your assigned jobs"` |
|
||||
| Requisition forms (`get_form_by_id`) | `is_admin` → all rows. Otherwise `created_by = me` |
|
||||
| Candidate hiring forms list | Hiring manager with no `form_id`, `inbox_id`, `manual_upload_candidate_id` or `job_post_id` → 403 `"Hiring managers can only load forms for candidates on their requisitions"`. Otherwise `assert_manager_candidate_access` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Role-name business rules (not tag-driven)
|
||||
|
||||
These rules look up role **names** and resolve ids from the `roles` table at request time.
|
||||
|
||||
| Rule | Where | Behaviour |
|
||||
|---|---|---|
|
||||
| Task creators | [backend/tasks/views.py](backend/tasks/views.py) | Route requires `tasks.create` **and** the caller's `role_id` must be one of the ids for `system_administrator`, `hr_administrator` or `recruiter`, else 403 `"Only system administrators, HR administrators and recruiters can create tasks"` |
|
||||
| Task assignee | tasks | Must be an existing, non-deleted user with role `recruiter`, else 422 `"Tasks can only be assigned to recruiter accounts"`. Omitting the assignee is allowed only when the caller is a recruiter, who then self-assigns |
|
||||
| Task assignee picker | `GET /tasks/assignees/fetch` (`tasks.view`) | All `recruiter` users, so the caller does not need `rbac_users.view` |
|
||||
| Job assignment roles | [backend/job/assignment/views.py](backend/job/assignment/views.py) | `primary_recruiter` → user must hold `recruiter`; `hiring_manager` → user must hold `hiring_manager`. Otherwise 422 `"{field} must be a {role}"`. The user must also be active and not deleted |
|
||||
| Application assignment | same | Assignee must be `recruiter` |
|
||||
| Job post recruiters / HM | [backend/job/job_post/views.py](backend/job/job_post/views.py) | `current_recruiter_ids` must be recruiters; `hiring_manager_id` must be a hiring_manager |
|
||||
| Inbox assign-recruiter | [backend/inbox/views.py](backend/inbox/views.py) | `recruiter_id` must be a `recruiter`, else 422 |
|
||||
| Hiring-manager directory | `GET /managers/fetch` (`jobs.view OR candidates.view OR job_board.create`) | Users with role `hiring_manager`. Returns 500 if that role is not seeded |
|
||||
| Recruiter performance | analytics | Iterates users whose role is `recruiter` |
|
||||
| Admin notifications | [backend/notifications/views.py](backend/notifications/views.py) | Recipients are users with role `system_administrator` |
|
||||
| Candidate identity | inbox / candidate / search models | Applicants are users with role `candidate` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Seeded bundles and who gets them
|
||||
|
||||
The **initial** roles and the original bundle set, including `all_access` for
|
||||
`system_administrator` (a fixed id list), were seeded outside this repo. Do not assume their
|
||||
contents; export them (see §10). The manual migrations below are in the repo and all run
|
||||
idempotently at startup via `alembic_setup.run_manual_sql()`.
|
||||
|
||||
| Bundle (`is_system=true`) | Tags | Attached to |
|
||||
|---|---|---|
|
||||
| `analytics_dashboard` (001) | all `dashboard.*`, `analytics.*`, `offers.*` + `interviews.view` | sysadmin, hr_admin, recruiter, hiring_manager, department_head, ceo |
|
||||
| `tasks_management` (004) | all `tasks.*` | sysadmin, hr_admin, recruiter (005 removed it from hiring_manager, department_head, ceo) |
|
||||
| `tasks_viewer` (005) | `tasks.view`, `tasks.export` | hiring_manager, department_head, ceo |
|
||||
| `talent_sourcing` (007) | all `talent.*` | the six staff roles |
|
||||
| `hiring_forms` (008) | `interviews.create`, `interviews.edit`, `interviews.delete` | the six staff roles |
|
||||
| `requisitions_management` (019) | all `requisitions.*` (**includes `.manage` and `.configure`**) | the six staff roles |
|
||||
| `manager_candidates` (024/025) | `candidates.view`, `candidates.create`, `candidates.edit` | hiring_manager (and a legacy `manager` role) |
|
||||
| `requisitions_self` (028) | `requisitions.view`, `requisitions.create`, `requisitions.edit` | none. Meant for custom roles |
|
||||
| `interviews_tab` (028) | `interviews.view`, `interviews.create`, `interviews.edit` | none. Meant for custom roles |
|
||||
| `department_management` (038) | all `department.*` | sysadmin, hr_admin |
|
||||
|
||||
"The six staff roles" means `system_administrator, hr_administrator, recruiter, hiring_manager,
|
||||
department_head, ceo`.
|
||||
|
||||
Pattern for adding a module (copy it exactly):
|
||||
|
||||
1. Add the module to `PermissionModule` **and** all 8 `PermissionTag` members (the startup assertion enforces this).
|
||||
2. Add the module to `MODULES` in `frontend/src/auth/permissions.js`.
|
||||
3. Write a manual SQL migration that inserts the 8 tags (`ON CONFLICT DO NOTHING`), creates a
|
||||
`<module>_management` bundle with `jsonb_agg(id ORDER BY id)` over that module, and
|
||||
appends the bundle id to the chosen roles guarded by
|
||||
`NOT (permissions @> jsonb_build_array(id))`.
|
||||
4. Guard the routes with `require_permission(PermissionTag.<MODULE>_<ACTION>)`.
|
||||
5. Add the route to `frontend/src/app/routes.js` with `permission: '<module>.view'`.
|
||||
6. Users must re-fetch `/users/me` (log in again) before the UI reflects the change.
|
||||
|
||||
**Consequence of the seed, if nobody has edited the matrix:** `requisitions_management` gives
|
||||
`requisitions.manage` to recruiter, hiring_manager and department_head. `is_admin()` is
|
||||
therefore true for recruiter and department_head, so they see every candidate, offer and
|
||||
requisition. Hiring managers stay scoped only because `is_hiring_manager` is checked first in
|
||||
`scopes_to_own_requisitions`. Verify this against the live export before relying on it.
|
||||
|
||||
---
|
||||
|
||||
## 9. Access Control editing (how grants change at runtime)
|
||||
|
||||
### 9.1 Endpoints — [backend/role/app.py](backend/role/app.py), [backend/role/views.py](backend/role/views.py)
|
||||
|
||||
| Method | Path | Tag | Behaviour |
|
||||
|---|---|---|---|
|
||||
| GET | `/roles/fetch[?record_id]` | `rbac_users.view` | Each role is expanded to `{..., permissions:[bundle ids], bundles:[bundle payloads with tag_names], effective_permissions:[resolved tag names]}` |
|
||||
| POST | `/roles/create` | `rbac_users.create` | `role_name` required (400); duplicate → 409 `"Role name already exists"`; always `is_system=false` |
|
||||
| PUT | `/roles/update?record_id` | `rbac_users.edit` | Partial update. Renaming a system role → 409 `"System roles cannot be renamed"`. May replace `permissions` (bundle ids) |
|
||||
| DELETE | `/roles/delete?record_id` | `rbac_users.delete` | Soft delete. System role → 409 `"System roles cannot be deleted"` |
|
||||
| PUT | `/roles/matrix/update?record_id` | `rbac_users.edit` | **Matrix save**, see §9.2 |
|
||||
| GET | `/permissions/fetch` | `rbac_users.view` | Bundles with `tag_names` |
|
||||
| POST | `/permissions/create` | `rbac_users.manage` | Always `is_system=false`; name clash → 409 |
|
||||
| PUT | `/permissions/update?record_id` | `rbac_users.manage` | Renaming a system bundle → 409 |
|
||||
| PUT | `/roles/permission-tags/update` | `rbac_users.manage` | Body `{id: bundleId, permission_tags:[...], name?, description?, is_active?}` sets the exact tag set on one shared bundle. Unknown or inactive tag ids → 422 `"Unknown or inactive permission tag ids: [...]"`. Every role holding the bundle is affected immediately |
|
||||
| GET | `/permission-tags/fetch` | `rbac_users.view` | Ordered by module, action |
|
||||
|
||||
All list endpoints accept `search`, `top`, `skip` and return `{data, total, status_code}`.
|
||||
404s: `"Role not found"`, `"Permission bundle not found"`, `"Permission tag not found"`.
|
||||
|
||||
### 9.2 Matrix save — `Role.set_role_matrix(role_id, tag_ids)`
|
||||
|
||||
```
|
||||
role must exist and not be deleted (404)
|
||||
tag_ids = sorted(unique(tag_ids))
|
||||
unknown or inactive ids → 422 "Unknown or inactive permission tag ids: [...]"
|
||||
overlay = bundle named f"role_{role.id}_matrix"
|
||||
if overlay is missing: create it (is_system=false, description "Access Control matrix for {role_name}")
|
||||
else: overwrite its permission_tags
|
||||
role.permissions = [overlay.id] # REPLACES every other bundle on the role
|
||||
return the role payload
|
||||
```
|
||||
|
||||
Shared system bundles are never mutated by the matrix. After the first save, a role's grant is
|
||||
exactly the ticked cells. The seeded bundles no longer apply to that role, even though they
|
||||
still exist.
|
||||
|
||||
### 9.3 Access Control screen — [frontend/src/screens/Rbac.jsx](frontend/src/screens/Rbac.jsx)
|
||||
|
||||
- Route `rbac`, gated on `rbac_users.view`.
|
||||
- The role list hides `role_name === 'candidate'`. System roles show a "System role" badge and
|
||||
have no delete action.
|
||||
- The matrix has rows = modules and columns = actions. Both are derived from `/permission-tags/fetch`
|
||||
in first-seen (id) order. A cell renders only if that tag exists; otherwise it shows `·`.
|
||||
- The draft starts from `role.effective_permissions`. Toggles are disabled without
|
||||
`rbac_users.edit`. Save is enabled only when the draft differs; it maps names to ids and
|
||||
calls `PUT /roles/matrix/update`.
|
||||
- Tooltip help: `requisitions.configure` = "Limit Jobs and Candidates to requisitions this user
|
||||
created. Independent of Create."; `candidates.manage` = "See every candidate, not only jobs
|
||||
this user owns."; `requisitions.manage` = "Org-wide requisition list (admin)."
|
||||
- The New/Edit Role form edits name, description, active flag and **bundle** picks, and shows a
|
||||
live preview of the union of `tag_names` from the picked bundles.
|
||||
|
||||
Settings screen: the *Approvals* tab lists `/users/pending-approvals` and its Approve button
|
||||
requires `rbac_users.edit`. The *Users* tab shows Pending / Awaiting approval / Active badges.
|
||||
|
||||
---
|
||||
|
||||
## 10. Export the live grants before mirroring
|
||||
|
||||
Seeds and matrix edits diverge over time. Take the effective role → tag map from the database
|
||||
rather than from §8:
|
||||
|
||||
```sql
|
||||
SELECT r.id, r.role_name, r.is_system, r.is_active, r.is_deleted,
|
||||
string_agg(DISTINCT p.name, ', ') AS bundles,
|
||||
count(DISTINCT t.id) AS tag_count,
|
||||
string_agg(DISTINCT t.tag_name, ', ' ORDER BY t.tag_name) AS effective_tags
|
||||
FROM app.roles r
|
||||
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(r.permissions, '[]'::jsonb)) rp(pid) ON true
|
||||
LEFT JOIN app.permissions p
|
||||
ON p.id = rp.pid::int AND p.is_active AND NOT p.is_deleted
|
||||
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(p.permission_tags, '[]'::jsonb)) pt(tid) ON true
|
||||
LEFT JOIN app.permission_tags t
|
||||
ON t.id = pt.tid::int AND t.is_active AND NOT t.is_deleted
|
||||
WHERE r.is_active AND NOT r.is_deleted
|
||||
GROUP BY r.id
|
||||
ORDER BY r.id;
|
||||
```
|
||||
|
||||
Also dump `app.permissions` (id, name, is_system, permission_tags) and `app.permission_tags`
|
||||
(id, tag_name) **with their ids**. Bundle and role arrays reference ids, so the ids must be
|
||||
preserved.
|
||||
|
||||
---
|
||||
|
||||
## 11. Frontend mirror (cosmetic gating; the server is authoritative)
|
||||
|
||||
### 11.1 Session and permission bootstrap — [frontend/src/auth/AuthProvider.jsx](frontend/src/auth/AuthProvider.jsx)
|
||||
|
||||
- The session (tokens + `data`) lives in `lib/tokenStore`. `GET /users/me` runs as a TanStack
|
||||
Query (`staleTime` 5 min, `retry: false`) whenever an access token exists. Its result is merged
|
||||
into the stored session so a page reload already has `permissions`.
|
||||
- `status` is `anonymous` (no token), `error` (me failed), `authenticated` (me data or
|
||||
cached permissions present), or `loading`.
|
||||
- `can(tag)` comes from `makeCan(permissions)`: **a null or undefined tag is always allowed**,
|
||||
otherwise it is set membership.
|
||||
- Sign-in invalidates the `me` query so the previous user's permissions cannot leak. Sign-out
|
||||
only clears client state. When a refresh fails, the user is sent to `/auth/login?expired=1`.
|
||||
|
||||
### 11.2 Route guard — [frontend/src/auth/RequireAuth.jsx](frontend/src/auth/RequireAuth.jsx)
|
||||
|
||||
`anonymous` → redirect to `/auth/login` (keeping `from`); `error` → `/auth/login?expired=1`;
|
||||
`loading` → full-page spinner, so the nav does not flash; a `permission` the user lacks →
|
||||
`<Forbidden/>` ("You don't have access to this page").
|
||||
|
||||
### 11.3 Route table — [frontend/src/app/routes.js](frontend/src/app/routes.js)
|
||||
|
||||
| path | permission | | path | permission |
|
||||
|---|---|---|---|---|
|
||||
| dashboard | `dashboard.view` | | interviews | `interviews.view` |
|
||||
| inbox | `inbox.view` | | requisitions | `requisitions.view` |
|
||||
| matching (hidden) | `candidates.view` | | assessments | `assessments.view` |
|
||||
| jobs | `jobs.view` | | offers | `offers.view` |
|
||||
| candidates | `candidates.view` | | managers | `jobs.view` |
|
||||
| cvbank | `candidates.view` | | departments | `department.view` |
|
||||
| pipeline | `pipeline.view` | | calendar | `interviews.view` |
|
||||
| progress | `jobs.view` | | reports | `reports.view` |
|
||||
| import | `candidates.create` | | analytics | `analytics.view` |
|
||||
| jobboard | `job_board.view` | | aistudio | *null* |
|
||||
| recruiterhub | `analytics.view` | | notifications | *null* |
|
||||
| talent | `talent.view` | | rbac | `rbac_users.view` |
|
||||
| tasks | `tasks.view` | | settings | `settings.view` |
|
||||
| aiassistant | *null* | | help | *null* |
|
||||
|
||||
Candidate detail sub-routes in `App.jsx` also require `candidates.view`.
|
||||
|
||||
### 11.4 Sidebar — [frontend/src/app/Sidebar.jsx](frontend/src/app/Sidebar.jsx)
|
||||
|
||||
A route is shown when `!hidden && can(permission) && (!isHiringManager(user) ||
|
||||
HIRING_MANAGER_NAV.has(path))`, where
|
||||
`HIRING_MANAGER_NAV = {candidates, requisitions, interviews, calendar, help, aiassistant, aistudio, notifications}`.
|
||||
A group heading renders only if at least one of its items survives.
|
||||
|
||||
### 11.5 Hiring-manager portal behaviour
|
||||
|
||||
- `Dashboard` redirects hiring managers to `/candidates`.
|
||||
- `Candidates` renders `<HiringManagerCandidates/>` for them; other users get the scoped or
|
||||
unscoped list via `seesAllCandidates` / `scopesToOwnRequisitions`.
|
||||
- The Favorite button on the candidate profile is hidden for hiring managers.
|
||||
|
||||
### 11.6 In-screen action gating (examples to mirror)
|
||||
|
||||
`jobs.edit` / `jobs.delete` on Jobs; `job_board.create` for Post Job; `pipeline.edit` to move a
|
||||
stage; `candidates.create` for notes and ATS re-run; `candidates.edit` for rating, favorite and
|
||||
matching assignment; `interviews.create || candidates.create` to schedule an interview;
|
||||
`offers.create` / `offers.edit` on the offer form; `assessments.create|edit|delete`;
|
||||
`reports.create|delete|export`; `requisitions.create|edit`; `department.create|edit`;
|
||||
`inbox.edit`; `talent.edit`; `settings.configure`; `rbac_users.edit` for approvals;
|
||||
`tasks.view|edit` on Recruiter Hub.
|
||||
Tasks "create" requires `can('tasks.create') && ['system_administrator','hr_administrator','recruiter'].includes(user.role_name)`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Known behaviour to reproduce (or consciously fix)
|
||||
|
||||
Mirror these exactly unless you have been told to change them, and record any deviation.
|
||||
|
||||
1. **Unauthenticated data routes:** `GET /email/fetch` and `GET /inbox/fetch` have no auth
|
||||
dependency. `GET /jobs/alias` is public.
|
||||
2. **No escalation check on role or bundle edits:** a holder of `rbac_users.edit` can set any
|
||||
tags on any role, **including their own**, through `/roles/update` or `/roles/matrix/update`.
|
||||
A holder of `rbac_users.manage` can do the same through the bundle endpoints. Only user↔role
|
||||
assignment is subset-checked (§5.5).
|
||||
3. `POST /users/create` **without** `role_id` skips the `rbac_users.manage` check entirely.
|
||||
4. Hardcoded ids: signup assigns `role_id = 4`. `Users.get_users`, `count_users`,
|
||||
`get_user_by_id`, `get_user_by_email` and `get_pending_approvals` exclude `role_id = 8`.
|
||||
Other code resolves roles by name.
|
||||
5. Name-based identities are matched case-insensitively and include legacy aliases: `manager`
|
||||
→ hiring manager, `admin` → admin.
|
||||
6. `requisitions.manage` makes a user an **admin** for candidate, offer and requisition scoping,
|
||||
not only for requisitions.
|
||||
7. No token revocation. A refresh token stays valid for 7 days after sign-out. Permission
|
||||
changes apply server-side on the next request, but the UI updates only after `/users/me`
|
||||
is refetched (re-login).
|
||||
8. Users on a deleted or inactive role can still log in, but they hold zero tags.
|
||||
9. Route handlers wrap unexpected exceptions as `HTTPException(500, detail=str(e))`, and the
|
||||
frontend may present any error as a permissions problem.
|
||||
10. Comments in `frontend/src/auth/permissions.js` and `routes.js` saying enforcement is
|
||||
"cosmetic, only /users/*, /roles/*, /permissions/* are enforced" are **stale**. As the
|
||||
table below shows, almost every route is now guarded server-side.
|
||||
11. `/email/sync` accepts either a JWT whose user holds `inbox.edit`, or a static
|
||||
`CRON_INBOX_SYNC_TOKEN` compared in constant time, for the scheduler.
|
||||
|
||||
---
|
||||
|
||||
## 13. Acceptance checks for a faithful mirror
|
||||
|
||||
- The server refuses to boot if the tag enum is not the full modules × actions product.
|
||||
- With no role → 403 `"User has no role assigned"` on any guarded route, while `/users/me` still returns 200.
|
||||
- Deactivating a bundle removes its tags from every role on the very next request, with no re-login.
|
||||
- Saving the matrix for role R creates or updates `role_R_matrix` and sets `R.permissions = [that id]`.
|
||||
- Assigning a role that holds a tag the caller lacks → 403, and the message lists the missing tags.
|
||||
- A hiring manager with every admin tag is still requisition-scoped.
|
||||
- A custom role with `requisitions.create` alone is **not** scoped. Adding `requisitions.configure` scopes it; adding `candidates.manage` unscopes it.
|
||||
- A recruiter without `candidates.manage` or `requisitions.manage` sees only jobs where they are a current recruiter, or which they created and which have no recruiters.
|
||||
- Task creation by `department_head`, even when holding `tasks.create`, → 403.
|
||||
- A frontend `can(null)` is true, and hiring managers see only the 8 locked nav items.
|
||||
- The frontend predicate tests in [frontend/permissions-scope.test.mjs](frontend/permissions-scope.test.mjs) pass against your implementation.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Every backend route and its guard
|
||||
|
||||
`AND` = `require_all=True`; `OR` = `require_all=False`. Row scoping (§6) and role-name rules
|
||||
(§7) apply on top of these guards. Generated from the `@router` decorators in `backend/*/app.py`.
|
||||
|
||||
| Domain | Method | Path | Required |
|
||||
|---|---|---|---|
|
||||
| analytics | GET | `/analytics/kpis/fetch` | analytics.view |
|
||||
| analytics | GET | `/analytics/funnel/fetch` | analytics.view |
|
||||
| analytics | GET | `/analytics/hiring-trend/fetch` | analytics.view |
|
||||
| analytics | GET | `/analytics/source-performance/fetch` | analytics.view |
|
||||
| analytics | GET | `/analytics/applications-per-job/fetch` | analytics.view |
|
||||
| analytics | POST | `/analytics/ask` | analytics.view |
|
||||
| analytics | GET | `/analytics/recruiter-performance/fetch` | analytics.view |
|
||||
| assessments | GET | `/assessments/fetch` | assessments.view |
|
||||
| assessments | GET | `/assessments/counts` | assessments.view |
|
||||
| assessments | POST | `/assessments/create` | assessments.create |
|
||||
| assessments | PATCH | `/assessments/update` | assessments.edit |
|
||||
| assessments | DELETE | `/assessments/delete` | assessments.delete |
|
||||
| assessments | POST | `/assessments/remind` | assessments.edit |
|
||||
| candidate_forms | GET | `/forms/requisition/search` | requisitions.view OR job_board.create OR jobs.create |
|
||||
| candidate_forms | GET | `/forms/requisition/fetch` | requisitions.view |
|
||||
| candidate_forms | POST | `/forms/requisition/create` | requisitions.create |
|
||||
| candidate_forms | PATCH | `/forms/requisition/update` | requisitions.edit |
|
||||
| candidate_forms | GET | `/forms/definitions` | interviews.view |
|
||||
| candidate_forms | GET | `/forms/fetch` | interviews.view |
|
||||
| candidate_forms | POST | `/forms/create` | interviews.create |
|
||||
| candidate_forms | PATCH | `/forms/update` | interviews.edit |
|
||||
| candidate_forms | DELETE | `/forms/delete` | interviews.delete |
|
||||
| department | GET | `/department/fetch` | department.view |
|
||||
| department | POST | `/department/create` | department.create |
|
||||
| department | PUT | `/department/update` | department.edit |
|
||||
| department | GET | `/department/heads/fetch` | department.create OR department.edit |
|
||||
| forget_password | POST | `/users/forget-password` | PUBLIC |
|
||||
| forget_password | POST | `/users/forget-password/verify-code` | PUBLIC |
|
||||
| forget_password | POST | `/users/forget-password/new-password` | PUBLIC |
|
||||
| g_sheet | GET | `/sheet/health` | PUBLIC |
|
||||
| g_sheet | GET | `/sheet/metadata` | settings.view |
|
||||
| g_sheet | GET | `/sheet/tabs` | settings.view |
|
||||
| g_sheet | GET | `/sheet/fetch` | settings.view |
|
||||
| g_sheet | POST | `/sheet/import` | settings.edit |
|
||||
| g_sheet | POST | `/sheet/{tab}/import` | settings.edit |
|
||||
| g_sheet | GET | `/sheet/import/fetch` | settings.view |
|
||||
| g_sheet | GET | `/sheet/form-data/sheets` | inbox.view OR settings.view |
|
||||
| g_sheet | GET | `/sheet/form-data/fetch` | inbox.view OR settings.view |
|
||||
| g_sheet | GET | `/sheet/form-data/counts` | inbox.view OR settings.view |
|
||||
| g_sheet | GET | `/sheet/form-data/count` | inbox.view OR settings.view |
|
||||
| g_sheet | GET | `/sheet/form-data/{record_id}` | inbox.view OR settings.view |
|
||||
| g_sheet | PATCH | `/sheet/form-data/{record_id}/assign-job-post` | inbox.edit OR settings.edit |
|
||||
| g_sheet | PATCH | `/sheet/form-data/{record_id}/processing-state` | inbox.edit OR settings.edit |
|
||||
| g_sheet | PATCH | `/sheet/form-data/{record_id}/duplicate` | inbox.edit OR settings.edit |
|
||||
| g_sheet | DELETE | `/sheet/form-data/{tab}/delete` | settings.delete |
|
||||
| g_sheet | POST | `/sheet/{tab}/append` | settings.edit |
|
||||
| g_sheet | PATCH | `/sheet/{tab}/update` | settings.edit |
|
||||
| g_sheet | POST | `/sheet/{tab}/clear` | settings.edit |
|
||||
| inbox | GET | `/email/fetch` | PUBLIC |
|
||||
| inbox | POST | `/email/sync` | custom: inbox_sync_caller |
|
||||
| inbox | GET | `/email/sync/fetch` | inbox.view |
|
||||
| inbox | GET | `/inbox/fetch` | PUBLIC |
|
||||
| inbox | POST | `/inbox/{record_id}/match` | inbox.edit |
|
||||
| inbox | PATCH | `/inbox/{record_id}/assign-job-post` | inbox.edit |
|
||||
| inbox | PATCH | `/inbox/{record_id}/assign-recruiter` | inbox.edit |
|
||||
| inbox | POST | `/inbox/{record_id}/read` | inbox.edit |
|
||||
| inbox | PATCH | `/inbox/read` | inbox.edit |
|
||||
| inbox | PATCH | `/inbox/read-all` | inbox.edit |
|
||||
| inbox | GET | `/inbox/{record_id}/read-status` | inbox.edit |
|
||||
| inbox | GET | `/inbox/all-applications` | inbox.view |
|
||||
| inbox | GET | `/inbox/all-applications/count` | inbox.view |
|
||||
| inbox | GET | `/inbox/counts` | inbox.view |
|
||||
| inbox | GET | `/inbox/triage` | inbox.view |
|
||||
| inbox | PATCH | `/inbox/triage/{record_id}/override` | inbox.edit |
|
||||
| inbox | PATCH | `/inbox/{record_id}/processing-state` | inbox.edit |
|
||||
| inbox | PATCH | `/inbox/{record_id}/duplicate` | inbox.edit |
|
||||
| inbox | POST | `/email/send` | inbox.edit |
|
||||
| inbox | POST | `/email/reply` | inbox.edit |
|
||||
| inbox | POST | `/inbox/rescan-on-hold` | inbox.edit |
|
||||
| inbox | GET | `/inbox/rescan-on-hold` | inbox.view |
|
||||
| interview | POST | `/interview/{interview_id}/calendar-event` | interviews.create |
|
||||
| interview | PATCH | `/interview/{interview_id}/calendar-event/reschedule` | interviews.edit |
|
||||
| interview | POST | `/interview/{interview_id}/calendar-event/cancel` | interviews.edit |
|
||||
| job | GET | `/jobs/alias` | PUBLIC |
|
||||
| job | POST | `/candidate/create/candidate` | candidates.create |
|
||||
| job | GET | `/candidate/fetch/users` | candidates.view |
|
||||
| job | GET | `/candidate/fetch/users/count` | candidates.view |
|
||||
| job | POST | `/candidate/cv_upload` | candidates.create |
|
||||
| job | POST | `/candidate/cv-bank/upload` | candidates.create |
|
||||
| job | GET | `/candidate/cv-bank/fetch` | candidates.view |
|
||||
| job | POST | `/candidate/cv-bank/score` | candidates.create |
|
||||
| job | GET | `/candidate/cv-bank/suggestions` | candidates.view |
|
||||
| job | GET | `/candidate/cv-bank/file` | candidates.view |
|
||||
| job | DELETE | `/candidate/cv-bank/delete` | candidates.delete |
|
||||
| job | GET | `/candidate/matching/fetch` | candidates.view |
|
||||
| job | GET | `/candidate/matching/fetch_by_id` | candidates.view |
|
||||
| job | POST | `/candidate/matching/assign` | candidates.edit |
|
||||
| job | POST | `/candidate/inbox-match` | candidates.edit |
|
||||
| job | POST | `/job/post-job` | job_board.create |
|
||||
| job | POST | `/job/image/upload` | job_board.create OR jobs.edit |
|
||||
| job | GET | `/job/image/fetch` | jobs.view OR job_board.view |
|
||||
| job | POST | `/job/assist-field` | job_board.create OR jobs.edit |
|
||||
| job | GET | `/job/buffer/channels` | job_board.view |
|
||||
| job | POST | `/candidate/score` | candidates.create |
|
||||
| job | POST | `/candidate/score_inbox` | candidates.create |
|
||||
| job | POST | `/candidate/ats-rerun` | candidates.create |
|
||||
| job | GET | `/candidate/scored/fetch` | candidates.view |
|
||||
| job | GET | `/job/fetch` | job_board.view OR candidates.view OR talent.view |
|
||||
| job | GET | `/job/stats/fetch` | jobs.view OR pipeline.view |
|
||||
| job | GET | `/job/departments/fetch` | job_board.view OR candidates.view OR talent.view OR jobs.view |
|
||||
| job | GET | `/jobs/requisition-statuses/fetch` | jobs.view |
|
||||
| job | GET | `/jobs/status-history/fetch` | jobs.view |
|
||||
| job | GET | `/jobs/fetch` | jobs.view |
|
||||
| job | GET | `/jobs/export` | jobs.export |
|
||||
| job | GET | `/candidate/fetch_by_id` | candidates.view |
|
||||
| job | GET | `/candidate/manager/fetch` | candidates.view |
|
||||
| job | GET | `/candidate/fetch` | candidates.view |
|
||||
| job | GET | `/candidate/applications/fetch` | candidates.view |
|
||||
| job | PATCH | `/candidate/update` | candidates.edit |
|
||||
| job | GET | `/candidate/history/fetch` | candidates.view |
|
||||
| job | GET | `/interview/fetch` | interviews.view OR candidates.view |
|
||||
| job | POST | `/interview/create` | interviews.create OR candidates.create |
|
||||
| job | PATCH | `/interview/update` | interviews.edit OR candidates.edit |
|
||||
| job | GET | `/notes/fetch` | candidates.view |
|
||||
| job | POST | `/notes/create` | candidates.create |
|
||||
| job | PATCH | `/notes/update` | candidates.edit |
|
||||
| job | GET | `/activity/fetch` | candidates.view |
|
||||
| job | POST | `/activity/create` | candidates.create |
|
||||
| job | GET | `/feedback/fetch` | candidates.view |
|
||||
| job | POST | `/feedback/create` | candidates.create |
|
||||
| job | PATCH | `/feedback/update` | candidates.edit |
|
||||
| job | PATCH | `/candidate/stage` | pipeline.edit |
|
||||
| job | GET | `/pipeline/candidates/fetch` | pipeline.view |
|
||||
| job | GET | `/pipeline/candidate/score/fetch` | pipeline.view |
|
||||
| job | GET | `/pipeline/transitions/fetch` | pipeline.view |
|
||||
| job | GET | `/job/assignments/fetch` | jobs.view |
|
||||
| job | POST | `/job/assignments/create` | jobs.edit |
|
||||
| job | GET | `/candidate/assignments/fetch` | candidates.view |
|
||||
| job | POST | `/candidate/assignments/create` | candidates.edit |
|
||||
| job | GET | `/job/costs/fetch` | jobs.view |
|
||||
| job | GET | `/job/costs/source-channels/fetch` | jobs.view |
|
||||
| job | POST | `/job/costs/create` | jobs.edit |
|
||||
| job | PATCH | `/jobs/update` | jobs.edit |
|
||||
| job | DELETE | `/jobs/delete` | jobs.delete |
|
||||
| job | PATCH | `/jobs/status` | jobs.edit |
|
||||
| job | GET | `/feedback/templates/fetch` | candidates.view |
|
||||
| job | POST | `/feedback/templates/create` | candidates.create |
|
||||
| job | PATCH | `/feedback/templates/update` | candidates.edit |
|
||||
| job | DELETE | `/feedback/templates/delete` | candidates.delete |
|
||||
| job | GET | `/documents/download` | candidates.view |
|
||||
| notifications | POST | `/users/confirm-email` | PUBLIC |
|
||||
| notifications | POST | `/users/confirm-email/resend` | PUBLIC |
|
||||
| notifications | GET | `/notifications/fetch` | any authenticated user |
|
||||
| notifications | POST | `/notifications/{record_id}/read` | any authenticated user |
|
||||
| notifications | POST | `/notifications/read-all` | any authenticated user |
|
||||
| notifications | DELETE | `/notifications/delete` | any authenticated user |
|
||||
| offer | GET | `/offers/fetch` | offers.view |
|
||||
| offer | POST | `/offers/create` | offers.create |
|
||||
| offer | PATCH | `/offers/update` | offers.edit |
|
||||
| offer | POST | `/offers/issue` | offers.approve |
|
||||
| offer | GET | `/offers/jobs/candidates/lists` | offers.view |
|
||||
| offer | POST | `/offers/jobs/sent` | offers.create |
|
||||
| org_settings | GET | `/org-settings/fetch` | settings.view |
|
||||
| org_settings | PUT | `/org-settings/update` | settings.configure |
|
||||
| org_settings | GET | `/org-settings/exclude-university/fetch` | settings.view |
|
||||
| org_settings | POST | `/org-settings/exclude-university/create` | settings.configure |
|
||||
| org_settings | POST | `/org-settings/exclude-university/create-batch` | settings.configure |
|
||||
| org_settings | PATCH | `/org-settings/exclude-university/update` | settings.configure |
|
||||
| org_settings | DELETE | `/org-settings/exclude-university/delete` | settings.configure |
|
||||
| org_settings | GET | `/org-settings/exclude-company/fetch` | settings.view |
|
||||
| org_settings | POST | `/org-settings/exclude-company/create` | settings.configure |
|
||||
| org_settings | POST | `/org-settings/exclude-company/create-batch` | settings.configure |
|
||||
| org_settings | PATCH | `/org-settings/exclude-company/update` | settings.configure |
|
||||
| org_settings | DELETE | `/org-settings/exclude-company/delete` | settings.configure |
|
||||
| reports | GET | `/reports/fetch` | reports.view |
|
||||
| reports | POST | `/reports/create` | reports.create |
|
||||
| reports | PATCH | `/reports/update` | reports.edit |
|
||||
| reports | DELETE | `/reports/delete` | reports.delete |
|
||||
| reports | POST | `/reports/run` | reports.view |
|
||||
| reports | GET | `/reports/export` | reports.export |
|
||||
| reports | GET | `/reports/runs/fetch` | reports.view |
|
||||
| role | GET | `/roles/fetch` | rbac_users.view |
|
||||
| role | POST | `/roles/create` | rbac_users.create |
|
||||
| role | PUT | `/roles/update` | rbac_users.edit |
|
||||
| role | DELETE | `/roles/delete` | rbac_users.delete |
|
||||
| role | GET | `/permissions/fetch` | rbac_users.view |
|
||||
| role | POST | `/permissions/create` | rbac_users.manage |
|
||||
| role | PUT | `/permissions/update` | rbac_users.manage |
|
||||
| role | PUT | `/roles/matrix/update` | rbac_users.edit |
|
||||
| role | PUT | `/roles/permission-tags/update` | rbac_users.manage |
|
||||
| role | GET | `/permission-tags/fetch` | rbac_users.view |
|
||||
| s3 | GET | `/s3/health` | PUBLIC |
|
||||
| s3 | POST | `/s3/upload` | candidates.create OR settings.edit |
|
||||
| s3 | GET | `/s3/url` | candidates.view OR settings.view |
|
||||
| s3 | GET | `/s3/open` | candidates.view OR settings.view OR inbox.view |
|
||||
| s3 | GET | `/s3/download` | candidates.view OR settings.view OR inbox.view |
|
||||
| s3 | POST | `/s3/delete` | candidates.delete OR settings.delete |
|
||||
| saved_search | GET | `/saved-searches/fetch` | any authenticated user |
|
||||
| saved_search | POST | `/saved-searches/create` | any authenticated user |
|
||||
| saved_search | PATCH | `/saved-searches/update` | any authenticated user |
|
||||
| saved_search | DELETE | `/saved-searches/delete` | any authenticated user |
|
||||
| search | GET | `/search/fetch` | jobs.view OR candidates.view |
|
||||
| talent | POST | `/talent/runs/start` | talent.create |
|
||||
| talent | GET | `/talent/runs/status` | talent.view |
|
||||
| talent | GET | `/talent/account` | talent.view |
|
||||
| talent | GET | `/talent/runs/fetch` | talent.view |
|
||||
| talent | GET | `/talent/profiles/fetch` | talent.view |
|
||||
| talent | GET | `/talent/profiles/fetch_by_id` | talent.view |
|
||||
| talent | PATCH | `/talent/profiles/outreach` | talent.edit |
|
||||
| talent | DELETE | `/talent/profiles/delete` | talent.delete |
|
||||
| tasks | GET | `/tasks/fetch` | tasks.view |
|
||||
| tasks | GET | `/tasks/assignees/fetch` | tasks.view |
|
||||
| tasks | POST | `/tasks/create` | tasks.create |
|
||||
| tasks | PATCH | `/tasks/update` | tasks.edit |
|
||||
| tasks | DELETE | `/tasks/delete` | tasks.delete |
|
||||
| users | POST | `/users/login` | PUBLIC |
|
||||
| users | POST | `/users/signup` | PUBLIC |
|
||||
| users | POST | `/users/refresh` | PUBLIC |
|
||||
| users | GET | `/users/me` | any authenticated user |
|
||||
| users | POST | `/users/create` | rbac_users.create |
|
||||
| users | GET | `/users/pending-approvals` | settings.view |
|
||||
| users | PUT | `/users/approve` | rbac_users.edit |
|
||||
| users | GET | `/users/fetch` | rbac_users.view |
|
||||
| users | PUT | `/users/update` | rbac_users.edit |
|
||||
| users | PUT | `/users/assign-role` | rbac_users.edit |
|
||||
| users | PUT | `/users/remove-role` | rbac_users.edit |
|
||||
| users | DELETE | `/users/delete` | rbac_users.delete |
|
||||
| users | GET | `/managers/fetch` | jobs.view OR candidates.view OR job_board.create |
|
||||
12
appspec.yml
12
appspec.yml
|
|
@ -1,12 +0,0 @@
|
|||
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,311 +4,127 @@ Copy everything below the line into any LLM session before asking it to write or
|
|||
|
||||
---
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Reference flow (the canonical example)
|
||||
## Goal
|
||||
|
||||
`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.
|
||||
Every change must look like it was written by the same author as `backend/users/` and `backend/inbox/`.
|
||||
|
||||
## Package layout (every domain)
|
||||
|
||||
```
|
||||
backend/<domain>/
|
||||
app.py # routes + inline request models
|
||||
views.py # service class
|
||||
models.py # SQLModel table + classmethod accessors
|
||||
serializers.py # serialize_* dict builders
|
||||
enums.py # str Enums + nested request BaseModel blocks
|
||||
plugins.py # helpers + constants
|
||||
permissions.py # auth domains only
|
||||
app.py # routes only — HTTP in/out
|
||||
views.py # service class — business logic
|
||||
models.py # SQLModel table + classmethod DB accessors
|
||||
serializers.py # hand-rolled dict builders (no Pydantic response models)
|
||||
plugins.py # pure helpers (hash, JWT, clean payload) — NO FastAPI imports
|
||||
permissions.py # OAuth2 scheme + Depends aliases (auth domains only)
|
||||
```
|
||||
|
||||
- No `__init__.py`. Run from `backend/`; imports are top-level (`candidate_forms.views`, `db_setup`, `users.permissions`).
|
||||
- Config: module-level `load_dotenv()` + `os.getenv(...)`. Do not extend `db_setup.Settings` for app secrets.
|
||||
- Bare `router = APIRouter()`; mount in `main.py` with `app.include_router(...)`.
|
||||
- No package `__init__.py`. Run from `backend/` so imports are top-level (`users.app`, `db_setup`).
|
||||
- Non-DB config: module-level `load_dotenv()` + `os.getenv(...)`. Do **not** extend `db_setup.Settings` for app secrets.
|
||||
|
||||
## Layer duties
|
||||
## Layer duties (non-negotiable)
|
||||
|
||||
| Layer | Owns | Must NOT |
|
||||
| Layer | Owns | Must NOT do |
|
||||
|---|---|---|
|
||||
| `app.py` | Routes, inline request models, `Depends`, `JSONResponse` envelope | Business rules, SQL, helper functions |
|
||||
| `views.py` | Payload prep, business checks, `HTTPException`, call model, return serializer | SQL, commits, helper definitions |
|
||||
| `models.py` | Columns, relationships, queries, insert/update/soft-delete + commit | `HTTPException`, serializers |
|
||||
| `serializers.py` | `serialize_*` → `dict` | DB, `Depends` |
|
||||
| `enums.py` | Enums, nested request blocks | Logic |
|
||||
| `plugins.py` | Every helper and constant | Routes, DB writes |
|
||||
| `app.py` | Routes, inline request Pydantic models, `JSONResponse`, HTTP token envelope via serializers, inject `session` / `CurrentUser` | Business rules, SQL, JWT crypto beyond calling plugin functions |
|
||||
| `views.py` | Business checks, call models, raise `HTTPException`, return ORM user (auth) or serialized dict (CRUD) | Call `serialize_token` or build login HTTP payloads |
|
||||
| `models.py` | Fields, queries, inserts/updates/soft-delete, `selectinload` when needed | HTTPException, FastAPI, serializers |
|
||||
| `serializers.py` | `serialize_*` → plain `dict` | DB, Depends |
|
||||
| `plugins.py` | Pure helpers; raise library errors (`jwt.*`) | Import FastAPI / raise HTTPException |
|
||||
| `permissions.py` | `OAuth2PasswordBearer`, `get_current_user`, `CurrentUser` alias, `require_permission` | Route handlers |
|
||||
|
||||
## Workflow when adding an endpoint
|
||||
## Exact route pattern (`app.py`)
|
||||
|
||||
1. Migration SQL if the schema changes.
|
||||
2. Columns + one classmethod accessor in `models.py`.
|
||||
3. Enum / nested block in `enums.py` if the body has one.
|
||||
4. Helper in `plugins.py` only if a view needs one that does not already exist.
|
||||
5. `serialize_*` in `serializers.py` if the shape is new.
|
||||
6. Service method in `views.py`.
|
||||
7. Inline request model + route in `app.py` with the exact try/except + `JSONResponse` wrapper and `require_permission`.
|
||||
- Paths are verb-in-path: `/users/create`, `/users/fetch`, `/users/login` — **not** `/auth/token`, not REST-resource-only.
|
||||
- Standard wrapper on every handler:
|
||||
|
||||
## Auth (only when touching `users/`)
|
||||
```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))
|
||||
```
|
||||
|
||||
- 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.
|
||||
- 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 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`.
|
||||
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.
|
||||
|
||||
Before finishing, put the new code next to the reference flow above and remove anything the reference would not have.
|
||||
## Workflow when adding an endpoint
|
||||
|
||||
1. Model accessor (if DB).
|
||||
2. Service method in `views.py`.
|
||||
3. `serialize_*` if new shape.
|
||||
4. Route in `app.py` with the standard try/except + `JSONResponse`.
|
||||
5. Add `current_user: CurrentUser` or `Depends(require_permission(...))` if the route is protected.
|
||||
|
||||
Before finishing, re-read the touched files and confirm they still match a sibling file’s structure, naming, spacing, and response shape.
|
||||
|
|
|
|||
|
|
@ -13,12 +13,6 @@ Django-shaped aliases (same behaviour, different names):
|
|||
python alembic_setup.py upgrade # apply versions/*.py (like migrate)
|
||||
python alembic_setup.py stamp -r f3a7e5b34c86 # bookmark only; no DDL
|
||||
|
||||
One command for every pending change — schema from all models, manual SQL, RBAC:
|
||||
|
||||
python alembic_setup.py sync --dry-run > review.sql # print the SQL, run nothing
|
||||
python alembic_setup.py sync # apply it (drops skipped)
|
||||
python alembic_setup.py sync --allow-drops # also drop tables/columns/indexes
|
||||
|
||||
Named `alembic_setup` rather than `alembic` because `backend/` is on `sys.path`,
|
||||
so a module called `alembic.py` would shadow the installed package.
|
||||
"""
|
||||
|
|
@ -27,7 +21,6 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
|
@ -39,7 +32,6 @@ from alembic import command
|
|||
from alembic.autogenerate import compare_metadata, produce_migrations, render_python_code
|
||||
from alembic.config import Config
|
||||
from alembic.operations import Operations
|
||||
from alembic.operations import ops as alembic_ops
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
from alembic.script.revision import ResolutionError
|
||||
|
|
@ -48,15 +40,7 @@ from alembic.util.exc import CommandError
|
|||
from sqlalchemy import MetaData, text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from db_setup import (
|
||||
BASE_DIR,
|
||||
Base,
|
||||
close_db,
|
||||
create_schemas,
|
||||
database_url,
|
||||
get_engine,
|
||||
get_settings,
|
||||
)
|
||||
from db_setup import BASE_DIR, Base, close_db, database_url, get_engine, get_settings
|
||||
|
||||
logger = logging.getLogger("db.alembic")
|
||||
|
||||
|
|
@ -198,7 +182,6 @@ def config(connection: Connection | None = None) -> Config:
|
|||
|
||||
VERSION_TABLE = "alembic_version"
|
||||
MANUAL_TABLE = "manual_migrations"
|
||||
RBAC_LEDGER_TABLE = "rbac_sync_ledger" # mirrors role.plugins.RBAC_LEDGER_TABLE
|
||||
|
||||
|
||||
def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to: Any) -> bool:
|
||||
|
|
@ -211,7 +194,7 @@ def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to
|
|||
return False
|
||||
if type_ != "table":
|
||||
return True
|
||||
if name in (VERSION_TABLE, MANUAL_TABLE, RBAC_LEDGER_TABLE): # bookkeeping; never ours to alter
|
||||
if name in (VERSION_TABLE, MANUAL_TABLE): # migration bookkeeping; never ours to alter
|
||||
return False
|
||||
return not s.db_schemas or (obj.schema or s.db_default_schema) in s.db_schemas
|
||||
|
||||
|
|
@ -351,56 +334,24 @@ async def downgrade(revision: str = "-1") -> None:
|
|||
logger.info("downgraded to %s", revision)
|
||||
|
||||
|
||||
_DESTRUCTIVE_OPS = (
|
||||
alembic_ops.DropTableOp,
|
||||
alembic_ops.DropColumnOp,
|
||||
alembic_ops.DropIndexOp,
|
||||
alembic_ops.DropConstraintOp,
|
||||
)
|
||||
|
||||
|
||||
def _pending_ops(connection: Connection) -> list[Any]:
|
||||
"""The ORM→DB diff as a flat list of Alembic operations, in revision-file order."""
|
||||
def _apply_upgrade_ops(connection: Connection) -> int:
|
||||
"""Apply ORM→DB diffs in-process without writing a revision file."""
|
||||
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
|
||||
ctx = MigrationContext.configure(connection, opts=opts)
|
||||
script = produce_migrations(ctx, target_metadata())
|
||||
flat: list[Any] = []
|
||||
|
||||
def walk(elem: Any) -> None:
|
||||
if hasattr(elem, "ops"):
|
||||
for child in elem.ops:
|
||||
walk(child)
|
||||
else:
|
||||
flat.append(elem)
|
||||
|
||||
walk(script.upgrade_ops)
|
||||
return flat
|
||||
|
||||
|
||||
def _invoke_ops(connection: Connection, ops: list[Any]) -> int:
|
||||
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
|
||||
operations = Operations(MigrationContext.configure(connection, opts=opts))
|
||||
for op in ops:
|
||||
operations.invoke(op)
|
||||
return len(ops)
|
||||
|
||||
|
||||
def _render_sql(ops: list[Any]) -> str:
|
||||
"""Render operations as PostgreSQL DDL without touching a database (Alembic offline mode)."""
|
||||
buf = io.StringIO()
|
||||
ctx = MigrationContext.configure(
|
||||
dialect_name="postgresql",
|
||||
opts={"as_sql": True, "output_buffer": buf, "literal_binds": True},
|
||||
)
|
||||
if script.upgrade_ops.is_empty():
|
||||
return 0
|
||||
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))
|
||||
applied = 0
|
||||
stack = [script.upgrade_ops]
|
||||
while stack:
|
||||
elem = stack.pop(0)
|
||||
if hasattr(elem, "ops"):
|
||||
stack.extend(elem.ops)
|
||||
else:
|
||||
operations.invoke(elem)
|
||||
applied += 1
|
||||
return applied
|
||||
|
||||
|
||||
async def apply_model_drift() -> bool:
|
||||
|
|
@ -507,107 +458,6 @@ async def run_manual_sql() -> None:
|
|||
logger.info("applied manual migration %s", path.name)
|
||||
|
||||
|
||||
async def _pending_manual_files() -> list[str]:
|
||||
"""Manual SQL files this database has not recorded yet. Read-only."""
|
||||
files = sorted(p.name for p in (MIGRATIONS / "manual").glob("*.sql") if p.is_file())
|
||||
schema = get_settings().db_default_schema
|
||||
table = f"{schema}.{MANUAL_TABLE}" if schema else MANUAL_TABLE
|
||||
async with get_engine().connect() as conn:
|
||||
driver = (await conn.get_raw_connection()).driver_connection
|
||||
if await driver.fetchval("SELECT to_regclass($1)", table) is None:
|
||||
return files
|
||||
applied = {r["filename"] for r in await driver.fetch(f"SELECT filename FROM {table}")}
|
||||
return [f for f in files if f not in applied]
|
||||
|
||||
|
||||
async def run_rbac_sync(*, dry_run: bool = False) -> list[str]:
|
||||
"""Bring permission tags, system roles and bundles up to role/plugins.py.
|
||||
|
||||
Returns the SQL it ran (or would run). Empty once the database matches the code,
|
||||
so running it on every boot is cheap.
|
||||
"""
|
||||
from role.plugins import RbacState, build_rbac_sql # local import: pulls in the app models
|
||||
|
||||
schema = get_settings().db_default_schema or None
|
||||
prefix = f"{schema}." if schema else ""
|
||||
async with get_engine().connect() as conn:
|
||||
driver = (await conn.get_raw_connection()).driver_connection
|
||||
if await driver.fetchval("SELECT to_regclass($1)", f"{prefix}roles") is None:
|
||||
logger.info("rbac sync skipped: roles table does not exist yet")
|
||||
return []
|
||||
has_ledger = (
|
||||
await driver.fetchval("SELECT to_regclass($1)", f"{prefix}{RBAC_LEDGER_TABLE}")
|
||||
) is not None
|
||||
async def column(sql: str) -> frozenset[Any]:
|
||||
return frozenset(tuple(r) if len(r) > 1 else r[0] for r in await driver.fetch(sql))
|
||||
|
||||
state = RbacState(
|
||||
tags=await column(f"SELECT tag_name FROM {prefix}permission_tags"),
|
||||
roles=await column(f"SELECT role_name FROM {prefix}roles"),
|
||||
bundles=await column(f"SELECT name FROM {prefix}permissions"),
|
||||
ledger=(
|
||||
await column(f"SELECT kind, key FROM {prefix}{RBAC_LEDGER_TABLE}")
|
||||
if has_ledger
|
||||
else frozenset()
|
||||
),
|
||||
)
|
||||
statements = build_rbac_sql(state, schema=schema)
|
||||
if statements and not dry_run:
|
||||
async with driver.transaction():
|
||||
for statement in statements:
|
||||
await driver.execute(statement)
|
||||
logger.info("rbac sync applied %s statement(s)", len(statements))
|
||||
return statements
|
||||
|
||||
|
||||
async def sync(*, dry_run: bool = False, allow_drops: bool = False) -> str:
|
||||
"""Every pending change in one pass: schema drift from all models, manual SQL, RBAC.
|
||||
|
||||
Returns the SQL as one reviewable script. `dry_run` executes nothing. Destructive
|
||||
schema operations are listed as comments and skipped unless `allow_drops`.
|
||||
"""
|
||||
header = ["-- DRY RUN: nothing was executed"] if dry_run else []
|
||||
# Rendered DDL names reflected tables without a schema, as `_run` resolves them.
|
||||
header.append(f'SET search_path TO "{get_settings().db_default_schema or "public"}", public;')
|
||||
out = list(header)
|
||||
async with _lock():
|
||||
pending: list[Any] = []
|
||||
if await _schema_is_empty():
|
||||
if dry_run:
|
||||
out.append("-- empty database: sync would create every model table from metadata")
|
||||
else:
|
||||
await bootstrap_empty()
|
||||
out.append("-- empty database: created every model table from metadata")
|
||||
else:
|
||||
pending = await _run(_pending_ops)
|
||||
|
||||
kept = [op for op in pending if allow_drops or not isinstance(op, _DESTRUCTIVE_OPS)]
|
||||
skipped = [op for op in pending if not allow_drops and isinstance(op, _DESTRUCTIVE_OPS)]
|
||||
if kept:
|
||||
out += [f"-- schema: {len(kept)} operation(s) from the models", _render_sql(kept)]
|
||||
if not dry_run:
|
||||
await _run(lambda c: _invoke_ops(c, kept))
|
||||
if skipped:
|
||||
rendered = "\n".join(f"-- {line}" for line in _render_sql(skipped).splitlines())
|
||||
header = f"-- skipped {len(skipped)} destructive operation(s); rerun with --allow-drops"
|
||||
out += [header, rendered]
|
||||
|
||||
manual = await _pending_manual_files()
|
||||
if manual:
|
||||
verb = "pending (RBAC plan below assumes they did not run)" if dry_run else "applied"
|
||||
out.append(f"-- manual SQL {verb}:\n" + "\n".join(f"-- {name}" for name in manual))
|
||||
if not dry_run:
|
||||
await run_manual_sql()
|
||||
|
||||
rbac = await run_rbac_sync(dry_run=dry_run)
|
||||
if rbac:
|
||||
out += [f"-- rbac: {len(rbac)} statement(s) from role/plugins.py", *rbac]
|
||||
|
||||
if len(out) == len(header):
|
||||
out.append("-- database is in sync with the code")
|
||||
return "\n\n".join(out) + "\n"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lock() -> AsyncIterator[None]:
|
||||
"""Advisory lock, so only one worker migrates when several boot at once."""
|
||||
|
|
@ -642,10 +492,6 @@ async def migrate(*, autogen: bool | None = None, message: str = "auto") -> None
|
|||
# ran above. Log and continue so the API can finish booting.
|
||||
logger.exception("ORM drift apply failed; continuing boot: %s", exc)
|
||||
await run_manual_sql()
|
||||
try:
|
||||
await run_rbac_sync()
|
||||
except Exception as exc:
|
||||
logger.exception("RBAC sync failed; continuing boot: %s", exc)
|
||||
logger.info("database at revision %s", await current())
|
||||
|
||||
|
||||
|
|
@ -664,15 +510,10 @@ def main(argv: Sequence[str] | None = None) -> None:
|
|||
"current",
|
||||
"head",
|
||||
"stamp",
|
||||
"sync",
|
||||
],
|
||||
)
|
||||
parser.add_argument("-m", "--message", default="auto", help="revision message")
|
||||
parser.add_argument("-r", "--revision", help="target revision")
|
||||
parser.add_argument("--dry-run", action="store_true", help="sync: print the SQL, run nothing")
|
||||
parser.add_argument(
|
||||
"--allow-drops", action="store_true", help="sync: also drop tables, columns, indexes"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s")
|
||||
|
||||
|
|
@ -698,10 +539,6 @@ def main(argv: Sequence[str] | None = None) -> None:
|
|||
print(head())
|
||||
elif args.command == "stamp":
|
||||
await stamp(args.revision or "head")
|
||||
elif args.command == "sync":
|
||||
if not args.dry_run:
|
||||
await create_schemas()
|
||||
print(await sync(dry_run=args.dry_run, allow_drops=args.allow_drops), end="")
|
||||
finally:
|
||||
await close_db()
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ async def search_requisitions(
|
|||
require_all=False,
|
||||
)
|
||||
),
|
||||
search: str | None = Query(None),
|
||||
q: str | None = Query(None),
|
||||
top: int = Query(50, ge=1, le=100),
|
||||
job_post_id: uuid.UUID | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
|
|
@ -91,7 +91,7 @@ async def search_requisitions(
|
|||
"""
|
||||
try:
|
||||
service = RequisitionForm(session=session)
|
||||
data = await service.search(search, top=top, job_post_id=job_post_id)
|
||||
data = await service.search(q, top=top, job_post_id=job_post_id)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -99,22 +99,6 @@ async def search_requisitions(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/forms/requisition/open-count")
|
||||
async def count_open_requisitions(
|
||||
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
|
||||
session:AsyncSession=Depends(get_session),
|
||||
):
|
||||
"""Open requisitions: unlinked, or linked to a job post that is still open."""
|
||||
try:
|
||||
service=RequisitionForm(session=session)
|
||||
data=await service.count_open(current_user)
|
||||
return JSONResponse(content={"data":{"open":data},"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/forms/requisition/fetch")
|
||||
async def fetch_requisition_form(
|
||||
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from enum import Enum
|
|||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
import uuid
|
||||
|
||||
class EmploymentType(str,Enum):
|
||||
PERMANENT = "permanent"
|
||||
CONTRACT = "contract"
|
||||
|
|
@ -10,7 +10,6 @@ class EmploymentType(str,Enum):
|
|||
INTERNEE="internee"
|
||||
|
||||
class Position(BaseModel):
|
||||
department_id:uuid.UUID
|
||||
department:Optional[str]
|
||||
title:Optional[str]
|
||||
date:Optional[date]
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import uuid
|
|||
from datetime import datetime, date as Date, timezone
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, Enum as SAEnum, JSON, and_, func, or_
|
||||
from sqlalchemy import DateTime, Enum as SAEnum, JSON, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, Relationship, SQLModel, select
|
||||
|
||||
from candidate_forms.enums import EmploymentType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from department.models import Department
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
|
||||
|
|
@ -20,11 +20,6 @@ class Requisition(SQLModel, table=True):
|
|||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
|
||||
department: Optional[str] = None
|
||||
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id")
|
||||
department_ref: Optional["Department"] = Relationship(
|
||||
back_populates="requisitions",
|
||||
sa_relationship_kwargs={"uselist": False, "lazy": "selectin"},
|
||||
)
|
||||
position_title: Optional[str] = None
|
||||
date: Optional[Date] = None
|
||||
date_needed: Optional[Date] = None
|
||||
|
|
@ -99,7 +94,7 @@ class Requisition(SQLModel, table=True):
|
|||
async def search(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
search: str | None = None,
|
||||
q: str | None = None,
|
||||
*,
|
||||
top: int = 50,
|
||||
job_post_id=None,
|
||||
|
|
@ -125,7 +120,7 @@ class Requisition(SQLModel, table=True):
|
|||
if except_uid is not None:
|
||||
held = held.where(JobPosts.id != except_uid)
|
||||
statement = statement.where(cls.id.notin_(held))
|
||||
term = (search or "").strip()
|
||||
term = (q or "").strip()
|
||||
if term:
|
||||
like = f"%{term}%"
|
||||
statement = statement.where(
|
||||
|
|
@ -136,32 +131,6 @@ class Requisition(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def count_open(cls, session: AsyncSession, created_by=None) -> int:
|
||||
"""Open requisitions: not linked to a live job post, or linked to one whose
|
||||
requisition_status is still open. A closed / on-hold / completed job closes
|
||||
its requisition. job_posts.requisition_id is 1:1, so the join never fans out.
|
||||
"""
|
||||
from job.job_post.enums import RequisitionStatus
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
statement = (
|
||||
select(func.count())
|
||||
.select_from(cls)
|
||||
.outerjoin(
|
||||
JobPosts,
|
||||
and_(JobPosts.requisition_id == cls.id, JobPosts.is_deleted == False), # noqa: E712
|
||||
)
|
||||
.where(
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
or_(JobPosts.id.is_(None), JobPosts.requisition_status == RequisitionStatus.OPEN.value),
|
||||
)
|
||||
)
|
||||
if created_by is not None:
|
||||
statement = statement.where(cls.created_by == created_by)
|
||||
result = await session.execute(statement)
|
||||
return int(result.scalar_one())
|
||||
|
||||
@classmethod
|
||||
async def insert_form(cls, session: AsyncSession, fields: dict):
|
||||
position = fields.get("position") if fields.get("position") else {}
|
||||
|
|
@ -169,7 +138,6 @@ class Requisition(SQLModel, table=True):
|
|||
referral = fields.get("refferal_by") if fields.get("refferal_by") else {}
|
||||
row = cls(
|
||||
department=position.get("department") if position.get("department") else None,
|
||||
department_id=position.get("department_id") if position.get("department_id") else None,
|
||||
position_title=position.get("title") if position.get("title") else None,
|
||||
date=position.get("date") if position.get("date") else None,
|
||||
date_needed=position.get("date_needed") if position.get("date_needed") else None,
|
||||
|
|
@ -213,8 +181,6 @@ class Requisition(SQLModel, table=True):
|
|||
position = fields.get("position") if fields.get("position") else {}
|
||||
if "department" in position:
|
||||
row.department = position.get("department") if position.get("department") else None
|
||||
if "department_id" in position:
|
||||
row.department_id = position.get("department_id") if position.get("department_id") else None
|
||||
if "title" in position:
|
||||
row.position_title = position.get("title") if position.get("title") else None
|
||||
if "date" in position:
|
||||
|
|
@ -425,4 +391,3 @@ class CandidateForms(SQLModel, table=True):
|
|||
|
||||
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
import department.models as _department_models # noqa: E402, F401
|
||||
|
|
@ -1,63 +1,11 @@
|
|||
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))
|
||||
"""Pure helpers for the hiring forms domain — no FastAPI, no DB.
|
||||
|
||||
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")
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ def serialize_requisition_option(row) -> dict:
|
|||
"id": str(row.id) if row.id else None,
|
||||
"title": row.position_title,
|
||||
"department": row.department,
|
||||
"department_id": str(row.department_id) if row.department_id else None,
|
||||
"label": f"{title or 'Untitled'} - {department or '—'}",
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +80,6 @@ def serialize_requisition(row) -> dict:
|
|||
"id": str(row.id) if row.id else None,
|
||||
"position": {
|
||||
"department": row.department,
|
||||
"department_id": str(row.department_id) if row.department_id else None,
|
||||
"title": row.position_title,
|
||||
"date": _date(row.date),
|
||||
"date_needed": _date(row.date_needed),
|
||||
|
|
|
|||
|
|
@ -14,13 +14,6 @@ from candidate_forms.plugins import (
|
|||
combined_summary,
|
||||
normalize_fields,
|
||||
normalize_sections,
|
||||
_as_uuid,
|
||||
_user_id,
|
||||
_aware,
|
||||
_stage_value,
|
||||
_recommendation,
|
||||
_score_sections,
|
||||
_score_fields,
|
||||
)
|
||||
from candidate_forms.serializers import (
|
||||
serialize_form,
|
||||
|
|
@ -39,6 +32,63 @@ from users.permissions import is_admin, is_hiring_manager
|
|||
logger = logging.getLogger("candidate_forms")
|
||||
|
||||
|
||||
def _as_uuid(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _user_id(current_user):
|
||||
if not current_user or not current_user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
uid = _as_uuid(current_user["id"])
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid user id")
|
||||
return uid
|
||||
|
||||
|
||||
def _aware(value):
|
||||
if value is not None and getattr(value, "tzinfo", None) is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
def _stage_value(status) -> str:
|
||||
return str(getattr(status, "value", status) or "").upper()
|
||||
|
||||
|
||||
def _recommendation(form_type, value):
|
||||
definition = FORM_DEFINITIONS.get(form_type) or {}
|
||||
if not definition.get("has_recommendation"):
|
||||
return None
|
||||
if value in (None, ""):
|
||||
return None
|
||||
value = str(value).strip()
|
||||
if value not in RECOMMENDATIONS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _score_sections(form_type, sections):
|
||||
try:
|
||||
return normalize_sections(form_type, sections)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
||||
def _score_fields(form_type, fields):
|
||||
try:
|
||||
return normalize_fields(form_type, fields)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
||||
class CandidateForm:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
|
@ -356,7 +406,6 @@ class RequisitionForm:
|
|||
self.session = session
|
||||
|
||||
async def create_form(self, payload, current_user):
|
||||
payload["position"]["department_id"] = _as_uuid(payload["position"]["department_id"])
|
||||
payload["created_by"] = _user_id(current_user)
|
||||
row = await Requisition.insert_form(self.session, payload)
|
||||
return serialize_requisition(row)
|
||||
|
|
@ -386,13 +435,8 @@ class RequisitionForm:
|
|||
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
|
||||
return [serialize_requisition(r) for r in rows]
|
||||
|
||||
async def 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):
|
||||
async def search(self, q, top=50, job_post_id=None):
|
||||
rows = await Requisition.search(
|
||||
self.session, search=search, top=top, job_post_id=job_post_id,
|
||||
self.session, q, top=top, job_post_id=job_post_id,
|
||||
)
|
||||
return [serialize_requisition_option(r) for r in rows]
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
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))
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
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,7 +11,6 @@ from dotenv import load_dotenv
|
|||
from taskiq import TaskiqEvents
|
||||
|
||||
from db_setup import session_scope
|
||||
import department.models # noqa: F401
|
||||
from inbox.models import MailboxSyncRun
|
||||
from inbox.views import Email
|
||||
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY
|
||||
|
|
|
|||
|
|
@ -1359,7 +1359,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
return row
|
||||
|
||||
@classmethod
|
||||
async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids,search=None,top=None,limit=None) -> dict[str, int]:
|
||||
async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids) -> dict[str, int]:
|
||||
"""Resolve {job_post_id: applicant_count} for a page of rows in a single query.
|
||||
|
||||
Counts Inbox_Messages rows, not Inbox rows: one message fans out to several
|
||||
|
|
@ -1373,12 +1373,6 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
.where(cls.assigned_job_post_id.in_(uids))
|
||||
.group_by(cls.assigned_job_post_id)
|
||||
)
|
||||
if search:
|
||||
result = result.where(cls.message_from.ilike(f"%{search}%"))
|
||||
if top:
|
||||
result = result.limit(top)
|
||||
if limit:
|
||||
result = result.limit(limit)
|
||||
return {str(job_id): int(n) for job_id, n in result.all()}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -2158,109 +2152,6 @@ class AtsResults(SQLModel, table=True):
|
|||
grouped.setdefault(row.form_data_id, []).append(row)
|
||||
return grouped
|
||||
|
||||
@classmethod
|
||||
async def latest_per_candidate_for_job(
|
||||
cls, session: AsyncSession, job_post_id, search=None, top=None, limit=None, skip=None,
|
||||
):
|
||||
"""Suggested candidates for one job: the newest score per person, best first.
|
||||
|
||||
A person is whichever identity the row carries — form_data_id,
|
||||
candidate_id or user_id (one is set at a time) — so DISTINCT ON their
|
||||
COALESCE, newest created_at winning. Rows carrying none are skipped.
|
||||
`search` matches the candidate's name, email, title or company; `top` and
|
||||
`limit` cap how many rows come back; `skip` is the page offset. Returns
|
||||
(row, user_name, user_email, form_name, form_email, candidate).
|
||||
"""
|
||||
from g_sheet.models import FormData
|
||||
from job.candidate.models import Candidates
|
||||
|
||||
jid = cls._as_uuid(job_post_id)
|
||||
if jid is None:
|
||||
return []
|
||||
identity = func.coalesce(cls.form_data_id, cls.candidate_id, cls.user_id)
|
||||
latest = (
|
||||
select(cls.id)
|
||||
.where(cls.job_post_id == jid, identity.is_not(None))
|
||||
.distinct(identity)
|
||||
.order_by(identity.desc(), cls.created_at.desc())
|
||||
.subquery()
|
||||
)
|
||||
statement = (
|
||||
select(cls, Users.name, Users.email, FormData.name, FormData.candidate_email, Candidates)
|
||||
.join(latest, cls.id == latest.c.id)
|
||||
.outerjoin(Users, cls.user_id == Users.id)
|
||||
.outerjoin(FormData, cls.form_data_id == FormData.id)
|
||||
.outerjoin(Candidates, cls.candidate_id == Candidates.id)
|
||||
.order_by(cls.overall_score.desc(), cls.created_at.desc(),cls.id.desc())
|
||||
)
|
||||
if search:
|
||||
like = f"%{search.strip()}%"
|
||||
statement = statement.where(
|
||||
or_(
|
||||
Users.name.ilike(like),
|
||||
Users.email.ilike(like),
|
||||
FormData.name.ilike(like),
|
||||
FormData.candidate_email.ilike(like),
|
||||
Candidates.candidate_name.ilike(like),
|
||||
Candidates.candidate_email.ilike(like),
|
||||
Candidates.job_title.ilike(like),
|
||||
Candidates.current_company.ilike(like),
|
||||
)
|
||||
)
|
||||
if skip:
|
||||
statement = statement.offset(skip)
|
||||
if top:
|
||||
statement = statement.limit(top)
|
||||
if limit:
|
||||
statement = statement.limit(limit)
|
||||
result = await session.execute(statement)
|
||||
return list(result.all())
|
||||
|
||||
@classmethod
|
||||
async def count_suggested_for_job(cls, session: AsyncSession, job_post_id, search=None) -> int:
|
||||
"""How many suggested candidates match `search` — the paged list's total.
|
||||
|
||||
Same rows as latest_per_candidate_for_job before its offset and limit.
|
||||
"""
|
||||
from g_sheet.models import FormData
|
||||
from job.candidate.models import Candidates
|
||||
|
||||
jid = cls._as_uuid(job_post_id)
|
||||
if jid is None:
|
||||
return 0
|
||||
identity = func.coalesce(cls.form_data_id, cls.candidate_id, cls.user_id)
|
||||
latest = (
|
||||
select(cls.id)
|
||||
.where(cls.job_post_id == jid, identity.is_not(None))
|
||||
.distinct(identity)
|
||||
.order_by(identity.desc(), cls.created_at.desc(),cls.id.desc())
|
||||
.subquery()
|
||||
)
|
||||
statement = (
|
||||
select(func.count())
|
||||
.select_from(cls)
|
||||
.join(latest, cls.id == latest.c.id)
|
||||
.outerjoin(Users, cls.user_id == Users.id)
|
||||
.outerjoin(FormData, cls.form_data_id == FormData.id)
|
||||
.outerjoin(Candidates, cls.candidate_id == Candidates.id)
|
||||
)
|
||||
if search:
|
||||
like = f"%{search.strip()}%"
|
||||
statement = statement.where(
|
||||
or_(
|
||||
Users.name.ilike(like),
|
||||
Users.email.ilike(like),
|
||||
FormData.name.ilike(like),
|
||||
FormData.candidate_email.ilike(like),
|
||||
Candidates.candidate_name.ilike(like),
|
||||
Candidates.candidate_email.ilike(like),
|
||||
Candidates.job_title.ilike(like),
|
||||
Candidates.current_company.ilike(like),
|
||||
)
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
return int(result.scalar_one())
|
||||
|
||||
@classmethod
|
||||
async def job_ids_by_emails(cls, session: AsyncSession, emails) -> dict:
|
||||
"""{email: {job_post_id, ...}} for any prior ATS score of these people."""
|
||||
|
|
|
|||
|
|
@ -143,7 +143,6 @@ class HiringCostCreate(BaseModel):
|
|||
class JobUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
department: str | None = None
|
||||
department_id: UUID | None = None
|
||||
location: str | None = None
|
||||
employment_type: str | None = None
|
||||
vacancies: int | None = None
|
||||
|
|
@ -153,6 +152,7 @@ class JobUpdate(BaseModel):
|
|||
experience_min: int | None = None
|
||||
experience_max: int | None = None
|
||||
description: str | None = None
|
||||
current_recruiter_id: UUID | None = None
|
||||
current_recruiter_ids: list[UUID] | None = None
|
||||
hiring_manager_id: UUID | None = None
|
||||
requisition_id: UUID | None = None
|
||||
|
|
@ -937,16 +937,13 @@ async def fetch_requisition_statuses(
|
|||
@router.get("/jobs/status-history/fetch")
|
||||
async def fetch_job_status_history(
|
||||
job_post_id:str=Query(...),
|
||||
search: str | None = Query(None),
|
||||
top: int | None = Query(10, ge=1, le=500),
|
||||
limit: int | None = Query(None, ge=1),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Who changed requisition_status on one job, from what, to what, and when."""
|
||||
try:
|
||||
service=JobPost(session=session)
|
||||
data=await service.fetch_status_history(job_post_id,search,top,limit)
|
||||
data=await service.fetch_status_history(job_post_id)
|
||||
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -986,29 +983,6 @@ async def fetch_jobs(
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/jobs/profile/fetch")
|
||||
async def fetch_job_profile(
|
||||
job_post_id: str = Query(...),
|
||||
search: str | None = Query(None),
|
||||
top: int | None = Query(None, ge=1),
|
||||
limit: int | None = Query(None, ge=1),
|
||||
skip: int | None = Query(None, ge=0),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Job profile page: the job row, suggested candidates (newest ats_results row
|
||||
per person, best score first) and the Suggested / Top Match header stats.
|
||||
`total` is how many suggested candidates match `search`, across all pages."""
|
||||
try:
|
||||
service=JobPost(session=session)
|
||||
data=await service.fetch_job_profile(job_post_id,current_user=current_user,search=search,top=top,limit=limit,skip=skip)
|
||||
return JSONResponse(content={"data":data,"total":data["total"],"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/jobs/export")
|
||||
async def export_jobs(
|
||||
search: str | None = Query(None),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ JOB_ASSIGNMENT_ROLES = {
|
|||
"hiring_manager": EnumRoles.HIRING_MANAGER,
|
||||
}
|
||||
JOB_OWNER_COLUMN = {
|
||||
"primary_recruiter": "current_recruiter_ids",
|
||||
"primary_recruiter": "current_recruiter_id",
|
||||
"hiring_manager": "hiring_manager_id",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1143,28 +1143,6 @@ class Candidates(SQLModel, table=True):
|
|||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def latest_completed_for_job_by_emails(cls, session: AsyncSession, job_id, emails) -> dict:
|
||||
"""{lower(email): newest completed row} against one job — the batch form of
|
||||
get_completed_by_email_job, for scores whose identity is a user or form row."""
|
||||
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||
jid = cls._as_uuid(job_id)
|
||||
if not lowers or jid is None:
|
||||
return {}
|
||||
result = await session.execute(
|
||||
select(cls)
|
||||
.where(
|
||||
func.lower(cls.candidate_email).in_(lowers),
|
||||
cls.job_id == jid,
|
||||
cls.status == "completed",
|
||||
)
|
||||
.order_by(cls.updated_at.desc())
|
||||
)
|
||||
out = {}
|
||||
for row in result.scalars():
|
||||
out.setdefault((row.candidate_email or "").strip().lower(), row)
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
async def list_by_emails(cls, session: AsyncSession, emails):
|
||||
"""Scored `candidates` rows for these addresses (ATS, not pipeline stage)."""
|
||||
|
|
@ -1301,7 +1279,9 @@ class Interviews(SQLModel, table=True):
|
|||
interview_type: str = Field(default="")
|
||||
interview_status: str = Field(default="")
|
||||
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
|
||||
|
||||
# Optional denorm so Recruiter Hub can join interviews → job_posts.current_recruiter_id
|
||||
# without walking inbox. Filled on create from the application's assigned job;
|
||||
# migration 011 added the columns. user_id is the candidate, not the recruiter.
|
||||
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
||||
graph_event_id: str | None = Field(default=None)
|
||||
|
|
@ -1362,6 +1342,9 @@ class Interviews(SQLModel, table=True):
|
|||
@classmethod
|
||||
def scoped_to_recruiter(cls, statement, recruiter_id):
|
||||
"""Restrict an Interviews select to the recruiter who owns the job.
|
||||
|
||||
COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id)
|
||||
→ job_posts.current_recruiter_id. Interviews with no job drop out.
|
||||
"""
|
||||
from inbox.models import Inbox, Inbox_Messages
|
||||
from job.job_post.models import JobPosts
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from job.job_post.enums import RequisitionStatus
|
|||
|
||||
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
|
||||
from candidate_forms.models import Requisition
|
||||
from department.models import Department
|
||||
from users.models import Users
|
||||
|
||||
|
||||
|
|
@ -26,11 +25,6 @@ class JobPosts(SQLModel, table=True):
|
|||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
title: str = Field(index=True)
|
||||
|
||||
# Many job posts -> one department. `department` (below) stays the free-text name that
|
||||
# analytics / filters / talent pool key off; set both together (see JobPost views).
|
||||
# The relationship is `department_ref` because `department` is already that column.
|
||||
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id", index=True)
|
||||
department_ref: Optional["Department"] = Relationship(back_populates="job_posts", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
|
||||
user: Optional["Users"] = Relationship(
|
||||
back_populates="job_posts",
|
||||
|
|
@ -60,7 +54,11 @@ class JobPosts(SQLModel, table=True):
|
|||
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
|
||||
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
|
||||
# Who is working the req now (swappable). History lives in job_assignments
|
||||
# with assignment_role=primary_recruiter; this column is the first / primary
|
||||
# pointer so existing joins keep working. current_recruiter_ids is the full
|
||||
# list (UUID strings) so more than one recruiter can sit on the same job.
|
||||
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
current_recruiter_ids: list[str] = Field(
|
||||
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"},
|
||||
)
|
||||
|
|
@ -90,36 +88,59 @@ class JobPosts(SQLModel, table=True):
|
|||
|
||||
@staticmethod
|
||||
def recruiter_ids_of(row) -> list[str]:
|
||||
"""The job's recruiters as UUID strings — current_recruiter_ids, nothing else.
|
||||
"""UUID strings currently assigned as recruiters on a job row or mapping.
|
||||
|
||||
Strings because every consumer keys names off str(uuid). Works on a JobPosts
|
||||
row and on a RowMapping from fetch_job_stats; both allow attribute access.
|
||||
Prefers current_recruiter_ids; falls back to current_recruiter_id so a
|
||||
row that has not been backfilled still maps to one person.
|
||||
"""
|
||||
return [str(i) for i in (getattr(row, "current_recruiter_ids", None) or [])]
|
||||
if isinstance(row, dict):
|
||||
raw = row.get("current_recruiter_ids")
|
||||
fallback = row.get("current_recruiter_id")
|
||||
else:
|
||||
raw = getattr(row, "current_recruiter_ids", None)
|
||||
fallback = getattr(row, "current_recruiter_id", None)
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw or []:
|
||||
uid = JobPosts._as_uuid(item)
|
||||
if uid is None:
|
||||
continue
|
||||
key = str(uid)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(key)
|
||||
if not out:
|
||||
uid = JobPosts._as_uuid(fallback)
|
||||
if uid is not None:
|
||||
out.append(str(uid))
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def has_recruiter(cls, recruiter_id):
|
||||
"""SQL: this recruiter is in current_recruiter_ids."""
|
||||
"""SQL: this recruiter is the primary pointer or in current_recruiter_ids."""
|
||||
uid = recruiter_id if isinstance(recruiter_id, uuid.UUID) else cls._as_uuid(recruiter_id)
|
||||
if uid is None:
|
||||
return false()
|
||||
return cls.current_recruiter_ids.contains([str(uid)])
|
||||
return or_(
|
||||
cls.current_recruiter_id == uid,
|
||||
cls.current_recruiter_ids.contains([str(uid)]),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def no_recruiters(cls):
|
||||
"""SQL: current_recruiter_ids names nobody — NULL or [].
|
||||
|
||||
coalesce covers both, so this must not be ANDed with an IS NULL test:
|
||||
an empty list is not NULL and would fall out of the result.
|
||||
"""
|
||||
return func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0
|
||||
"""SQL: neither the pointer nor the JSON list names anyone."""
|
||||
return and_(
|
||||
cls.current_recruiter_id.is_(None),
|
||||
func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(select(cls).where(cls.id == uid).order_by(cls.created_at.desc(),cls.id.desc()))
|
||||
result = await session.execute(select(cls).where(cls.id == uid))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
|
|
@ -480,9 +501,9 @@ class JobPosts(SQLModel, table=True):
|
|||
.subquery("job_reapplied")
|
||||
)
|
||||
|
||||
# No recruiter join here: current_recruiter_ids is a JSONB array, so a join
|
||||
# on it would emit one stats row per recruiter and multiply the counts.
|
||||
# The service resolves recruiter names from that column instead.
|
||||
# Alias so this join does not collide with the Users join inside
|
||||
# the manual-upload subquery above.
|
||||
Recruiter=aliased(Users)
|
||||
statement = (
|
||||
select(
|
||||
cls.id.label("job_post_id"),
|
||||
|
|
@ -490,8 +511,10 @@ class JobPosts(SQLModel, table=True):
|
|||
cls.department,
|
||||
cls.location,
|
||||
cls.requisition_status,
|
||||
cls.current_recruiter_id,
|
||||
cls.current_recruiter_ids,
|
||||
cls.created_at,
|
||||
Recruiter.name.label("recruiter_name"),
|
||||
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
|
||||
func.coalesce(stats.c.shortlisting, 0).label("shortlisting"),
|
||||
func.coalesce(stats.c.screened, 0).label("screened"),
|
||||
|
|
@ -507,6 +530,7 @@ class JobPosts(SQLModel, table=True):
|
|||
.select_from(cls)
|
||||
.outerjoin(stats, stats.c.job_post_id == cls.id)
|
||||
.outerjoin(reapplied_stats, reapplied_stats.c.job_post_id == cls.id)
|
||||
.outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id)
|
||||
.where(cls.is_deleted == False) # noqa: E712
|
||||
)
|
||||
if active_only:
|
||||
|
|
@ -861,22 +885,15 @@ class JobPostStatusHistory(SQLModel, table=True):
|
|||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
|
||||
@classmethod
|
||||
async def fetch_by_job(cls, session: AsyncSession, job_post_id,search=None,top=None,limit=None):
|
||||
async def fetch_by_job(cls, session: AsyncSession, job_post_id):
|
||||
uid = JobPosts._as_uuid(job_post_id)
|
||||
if uid is None:
|
||||
return []
|
||||
statement = (
|
||||
result = await session.execute(
|
||||
select(cls)
|
||||
.where(cls.job_post_id == uid)
|
||||
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||
)
|
||||
if search:
|
||||
statement = statement.where(cls.from_status.ilike(f"%{search}%") or cls.to_status.ilike(f"%{search}%"))
|
||||
if top:
|
||||
statement = statement.limit(top)
|
||||
if limit:
|
||||
statement = statement.limit(limit)
|
||||
result = await session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
|
|
@ -968,6 +985,3 @@ class SocialPlatform(SQLModel, table=True):
|
|||
# configure — JobPosts.requisition_id FKs to app.requisitions.
|
||||
import candidate_forms.models as _requisition_models # noqa: E402, F401
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
# JobPosts.department_ref resolves "Department" at mapper configure; workers that
|
||||
# never import the department app would otherwise fail to map JobPosts.
|
||||
import department.models as _department_models # noqa: E402, F401
|
||||
|
|
@ -281,75 +281,3 @@ async def list_buffer_channels() -> list[dict]:
|
|||
"organization_name": org.get("name"),
|
||||
})
|
||||
return channels
|
||||
|
||||
|
||||
# ats_results.band labels, best first — the same cut-offs CandidateView._recommendation writes.
|
||||
MATCH_BANDS = ("Strong Match", "Potential Match", "Weak Match")
|
||||
TOP_MATCH_BAND = MATCH_BANDS[0]
|
||||
|
||||
|
||||
def suggested_source(inbox_id, form_data_id, candidate_source=None) -> str:
|
||||
"""Where a suggested candidate's score came from: inbox | form | upload | bank."""
|
||||
if inbox_id is not None:
|
||||
return "inbox"
|
||||
if form_data_id is not None:
|
||||
return "form"
|
||||
return (candidate_source or "").strip() or "upload"
|
||||
|
||||
|
||||
def _skill_key(value) -> str:
|
||||
return " ".join(re.sub(r"[^a-z0-9+#]+", " ", str(value or "").lower()).split())
|
||||
|
||||
|
||||
def optional_skill_hits(optional_skills, matched_keywords) -> list[str]:
|
||||
"""Job optional skills the candidate's matched keywords cover, in the job's order.
|
||||
|
||||
A hit is an exact normalized match, or one side containing the other as whole
|
||||
words ("Salesforce" covers "CRM (Salesforce)"). Keywords are verified against the
|
||||
resume upstream, so this only has to line up two spellings of the same skill.
|
||||
"""
|
||||
keys = [k for k in (_skill_key(m) for m in matched_keywords or []) if k]
|
||||
hits = []
|
||||
for skill in optional_skills or []:
|
||||
target = _skill_key(skill)
|
||||
if not target or skill in hits:
|
||||
continue
|
||||
padded = f" {target} "
|
||||
if any(k == target or f" {k} " in padded or padded in f" {k} " for k in keys):
|
||||
hits.append(skill)
|
||||
return hits
|
||||
|
||||
|
||||
def suggested_summary(candidates) -> dict:
|
||||
"""Header stats for a job's suggested candidates: count, top-band count, best score."""
|
||||
bands = {band: 0 for band in MATCH_BANDS}
|
||||
scores = []
|
||||
for c in candidates or []:
|
||||
if c.get("band") in bands:
|
||||
bands[c["band"]] += 1
|
||||
if c.get("match_score") is not None:
|
||||
scores.append(c["match_score"])
|
||||
return {
|
||||
"suggested": len(candidates or []),
|
||||
"top_match": bands[TOP_MATCH_BAND],
|
||||
"top_score": max(scores) if scores else None,
|
||||
"bands": bands,
|
||||
}
|
||||
|
||||
|
||||
def job_people_of(row, people) -> dict:
|
||||
"""One job's slice of a page-wide {"recruiters": …, "hiring_manager": …} lookup.
|
||||
|
||||
Users.job_people resolves every id on the page in two queries; this picks out
|
||||
the names belonging to one row, keeping the two roles in their own maps.
|
||||
"""
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
recruiters = (people or {}).get("recruiters") or {}
|
||||
managers = (people or {}).get("hiring_manager") or {}
|
||||
manager_id = getattr(row, "hiring_manager_id", None)
|
||||
key = str(manager_id) if manager_id else None
|
||||
return {
|
||||
"recruiters": {rid: recruiters.get(rid) for rid in JobPosts.recruiter_ids_of(row)},
|
||||
"hiring_manager": {key: managers[key]} if key and key in managers else {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,20 +21,15 @@ def serialize_job_post_title(row) -> dict:
|
|||
|
||||
|
||||
def _recruiter_payload(row, names=None):
|
||||
"""Recruiter ids plus their mapped names, for the payloads that still send the
|
||||
flat shape (serialize_job_post, serialize_job_stats)."""
|
||||
"""List of recruiter ids plus mapped names; first id stays the legacy pointer."""
|
||||
names = names or {}
|
||||
ids = JobPosts.recruiter_ids_of(row)
|
||||
mapped = [names.get(i) for i in ids]
|
||||
# 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
|
||||
first = ids[0] if ids else None
|
||||
return {
|
||||
"current_recruiter_id": first,
|
||||
"current_recruiter_ids": ids,
|
||||
"recruiter_name": recruiter_name,
|
||||
"recruiter_name": next((n for n in mapped if n), None),
|
||||
"recruiter_names": [n for n in mapped if n],
|
||||
"recruiters": [{"id": i, "name": names.get(i)} for i in ids],
|
||||
}
|
||||
|
|
@ -46,7 +41,6 @@ def serialize_job_post(row, *, names=None) -> dict:
|
|||
"title": row.title,
|
||||
# Talent Pool / candidate filters key off attached job_posts.department.
|
||||
"department": row.department or None,
|
||||
"department_id": str(row.department_id) if row.department_id else None,
|
||||
"employment_type": row.employment_type,
|
||||
"location": row.location,
|
||||
"experience_min": row.experience_min,
|
||||
|
|
@ -74,31 +68,27 @@ def serialize_job_post(row, *, names=None) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def serialize_job_row(row, *, people=None, applicant_count=0) -> dict:
|
||||
"""Requisition view of a job post, for the Jobs screen and the job profile.
|
||||
def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict:
|
||||
"""Requisition view of a job post, for the Jobs screen.
|
||||
|
||||
Deliberately separate from serialize_job_post: that payload is shared by the
|
||||
inbox, candidate and matching paths. department is the one shared field —
|
||||
talent-pool filters key off it on attached job_posts.
|
||||
|
||||
`people` is Users.job_people's shape — {"recruiters": {id: name},
|
||||
"hiring_manager": {id: name}} — so the two roles stay apart in the response.
|
||||
"""
|
||||
req = getattr(row, "requisition", None)
|
||||
people = people or {}
|
||||
recruiters = people.get("recruiters") or {}
|
||||
hiring_manager = people.get("hiring_manager") or None
|
||||
payload = _recruiter_payload(row, names)
|
||||
if recruiter_name and not payload["recruiter_name"]:
|
||||
payload["recruiter_name"] = recruiter_name
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"title": row.title,
|
||||
# The linked department wins; the free-text column is the fallback, and is
|
||||
# still what fetch_jobs filters on, so reads and filters agree.
|
||||
"department": (row.department_ref.name if row.department_ref else None) or row.department or None,
|
||||
"department_id": str(row.department_id) if row.department_id else None,
|
||||
"department": row.department or None,
|
||||
"location": row.location,
|
||||
"employment_type": row.employment_type,
|
||||
"vacancies": row.vacancies,
|
||||
"platform": row.platform or None,
|
||||
# Two different lifecycles, never conflate: requisition_status is hiring
|
||||
# (open/closed/on_hold), status is Buffer publishing (draft/scheduled/...).
|
||||
"requisition_status": row.requisition_status,
|
||||
"status": row.status,
|
||||
"experience_min": row.experience_min,
|
||||
|
|
@ -109,6 +99,9 @@ def serialize_job_row(row, *, people=None, applicant_count=0) -> dict:
|
|||
"description": row.description,
|
||||
"is_active": row.is_active,
|
||||
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
|
||||
**payload,
|
||||
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
|
||||
"hiring_manager_name": hiring_manager_name,
|
||||
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
|
||||
"requisition_title": req.position_title if req else None,
|
||||
"requisition_department": req.department if req else None,
|
||||
|
|
@ -117,8 +110,6 @@ def serialize_job_row(row, *, people=None, applicant_count=0) -> dict:
|
|||
"created_by_name": row.user.name if getattr(row, "user", None) else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
"recruiters": recruiters,
|
||||
"hiring_manager": hiring_manager,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -164,30 +155,3 @@ def serialize_status_history(row, *, changed_by_name=None) -> dict:
|
|||
"actor_kind": row.actor_kind,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_suggested_candidate(row, *, name=None, email=None, candidate=None, source=None, optional_matched=None) -> dict:
|
||||
"""One suggested candidate on the job profile: the newest ats_results row for a
|
||||
person, with profile fields from its `candidates` row when one exists."""
|
||||
score = row.overall_score
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"user_id": str(row.user_id) if row.user_id else None,
|
||||
"candidate_id": str(row.candidate_id) if row.candidate_id else None,
|
||||
"form_data_id": str(row.form_data_id) if row.form_data_id else None,
|
||||
"inbox_id": row.inbox_id,
|
||||
"name": name or (candidate.candidate_name if candidate else None) or email or None,
|
||||
"email": email or (candidate.candidate_email if candidate else None) or None,
|
||||
"current_title": candidate.job_title if candidate else None,
|
||||
"current_company": candidate.current_company if candidate else None,
|
||||
"years_experience": candidate.years_experience if candidate else None,
|
||||
"match_score": round(score) if score is not None else None,
|
||||
"band": row.band or None,
|
||||
"matched_keywords": list(candidate.matched_keywords or []) if candidate else [],
|
||||
"missing_keywords": list(candidate.missing_keywords or []) if candidate else [],
|
||||
"optional_matched": list(optional_matched or []),
|
||||
"summary": (candidate.summary_critique if candidate else None) or row.professional_summary or None,
|
||||
"source": source,
|
||||
"scored_candidate_id": str(candidate.id) if candidate else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
from typing import Any
|
||||
|
||||
|
||||
from datetime import date, time
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -14,28 +11,23 @@ from fastapi import HTTPException
|
|||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, model_validator
|
||||
from inbox.models import AtsResults,Inbox_Messages
|
||||
from inbox.models import Inbox_Messages
|
||||
from job.assignment.views import Assignment
|
||||
from job.candidate.models import Candidates
|
||||
from job.job_post.enums import RequisitionStatus
|
||||
from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform
|
||||
from role.models import EnumRoles
|
||||
from users.models import Users
|
||||
from job.job_post.plugins import (
|
||||
BufferError,
|
||||
job_people_of,
|
||||
create_buffer_post,
|
||||
list_buffer_channels,
|
||||
local_status,
|
||||
normalize_platform,
|
||||
optional_skill_hits,
|
||||
parse_buffer_datetime,
|
||||
render_job_post,
|
||||
resolve_channel,
|
||||
suggested_source,
|
||||
suggested_summary,
|
||||
)
|
||||
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history, serialize_suggested_candidate
|
||||
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history
|
||||
|
||||
load_dotenv()
|
||||
logger=logging.getLogger("job.job_post")
|
||||
|
|
@ -49,20 +41,28 @@ MAX_JOB_IMAGE_BYTES=5*1024*1024
|
|||
|
||||
|
||||
def _payload_recruiter_ids(payload):
|
||||
"""None = the client did not send recruiters, so leave them alone.
|
||||
[] = the client sent an empty list, so remove every recruiter.
|
||||
"""Prefer current_recruiter_ids; fall back to current_recruiter_id. None = omitted."""
|
||||
has_list="current_recruiter_ids" in payload and payload.get("current_recruiter_ids") is not None
|
||||
has_one="current_recruiter_id" in payload
|
||||
if has_list:
|
||||
raw=payload.get("current_recruiter_ids") or []
|
||||
if not isinstance(raw,(list,tuple)):
|
||||
raw=[raw]
|
||||
ids=list(raw)
|
||||
if not ids and has_one and payload.get("current_recruiter_id") not in (None,""):
|
||||
ids=[payload.get("current_recruiter_id")]
|
||||
return ids
|
||||
if has_one:
|
||||
raw=payload.get("current_recruiter_id")
|
||||
return [] if raw in (None,"") else [raw]
|
||||
return None
|
||||
|
||||
Checks for the key rather than truthiness: an empty list is falsy, and
|
||||
reading it as "not sent" made the last recruiter impossible to remove.
|
||||
"""
|
||||
if "current_recruiter_ids" not in payload:
|
||||
return None
|
||||
return payload.get("current_recruiter_ids") or []
|
||||
|
||||
def _recruiter_fields(users):
|
||||
ids=[str(u.id) for u in users]
|
||||
return {
|
||||
"current_recruiter_ids": ids,
|
||||
"current_recruiter_id": users[0].id if users else None,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -83,7 +83,6 @@ class JobPostCreate(BaseModel):
|
|||
location: str | None = None
|
||||
employment_type: str | None = None
|
||||
department: str | None = None
|
||||
department_id: UUID | None = None
|
||||
vacancies: int = 1
|
||||
description: str | None = None
|
||||
platform: str | None = None
|
||||
|
|
@ -93,6 +92,7 @@ class JobPostCreate(BaseModel):
|
|||
scheduler_date: date | None = None
|
||||
due_at: str | None = None
|
||||
hiring_manager_id: UUID | None = None
|
||||
current_recruiter_id: UUID | None = None
|
||||
current_recruiter_ids: list[UUID] | None = None
|
||||
requisition_id: UUID | None = None
|
||||
|
||||
|
|
@ -181,7 +181,6 @@ class JobPost:
|
|||
"salary":payload.get("salary") or "Anonymous",
|
||||
# department is NOT NULL with a server_default of "" — pass "", never None.
|
||||
"department":payload.get("department") or "",
|
||||
"department_id":None,
|
||||
"vacancies":payload.get("vacancies") or 1,
|
||||
"description":payload.get("description"),
|
||||
"post_text":text,
|
||||
|
|
@ -192,10 +191,6 @@ class JobPost:
|
|||
# Only set platform when it is actually known: passing None would override the
|
||||
# column default and break the NOT NULL constraint. Buffer's channelService
|
||||
# replaces this with the authoritative value once the post is created.
|
||||
if payload.get("department_id"):
|
||||
department=await self._require_department(payload.get("department_id"))
|
||||
fields["department_id"]=department.id
|
||||
fields["department"]=department.name
|
||||
known_platform=service or normalize_platform(payload.get("platform"),aliases)
|
||||
if known_platform:
|
||||
fields["platform"]=known_platform
|
||||
|
|
@ -375,12 +370,12 @@ class JobPost:
|
|||
async def fetch_requisition_statuses(self):
|
||||
return RequisitionStatus.as_list()
|
||||
|
||||
async def fetch_status_history(self,job_post_id,search=None,top=None,limit=None):
|
||||
async def fetch_status_history(self,job_post_id):
|
||||
job=await JobPosts.get_job_post_by_id(self.session,job_post_id)
|
||||
if not job or job.is_deleted:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id,search,top,limit)
|
||||
names=await Users.names_by_ids(self.session,[r.changed_by for r in rows],search,top,limit)
|
||||
rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id)
|
||||
names=await Users.names_by_ids(self.session,[r.changed_by for r in rows])
|
||||
return [
|
||||
serialize_status_history(r,changed_by_name=names.get(str(r.changed_by)))
|
||||
for r in rows
|
||||
|
|
@ -403,87 +398,31 @@ class JobPost:
|
|||
employment_type=employment_type,hiring_manager_id=hm_uid,
|
||||
restrict_ids=restrict,
|
||||
)
|
||||
people=await Users.job_people(
|
||||
names=await Users.names_by_ids(
|
||||
self.session,
|
||||
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)],
|
||||
[r.hiring_manager_id for r in rows],
|
||||
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)]+[r.hiring_manager_id for r in rows],
|
||||
)
|
||||
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
|
||||
return [
|
||||
serialize_job_row(
|
||||
r,
|
||||
people=job_people_of(r,people),
|
||||
names=names,
|
||||
hiring_manager_name=names.get(str(r.hiring_manager_id)),
|
||||
applicant_count=counts.get(str(r.id),0),
|
||||
)
|
||||
for r in rows
|
||||
],total
|
||||
|
||||
async def fetch_job_profile(self,job_post_id,current_user=None,search=None,top=None,limit=None,skip=None):
|
||||
"""Job profile page: the requisition row, its suggested candidates and the
|
||||
Suggested / Top Match header stats — one round trip.
|
||||
|
||||
Suggested = newest ats_results row per person for this job. Profile fields
|
||||
and keywords come from that score's candidates row; a user- or form-identity
|
||||
score has none, so it borrows the newest completed row for the same email."""
|
||||
uid=JobPosts._as_uuid(job_post_id)
|
||||
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=422,detail="job_post_id must be a UUID")
|
||||
|
||||
# it's a system admin job profile page, so we don't need to restrict the ids
|
||||
restrict=await self._restrict_ids_for_requisition_scope(current_user)
|
||||
|
||||
if restrict is not None and str(uid) not in {str(i) for i in restrict}:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
|
||||
job=await JobPosts.get_job_post_by_id(self.session,uid)
|
||||
if not job or job.is_deleted:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
|
||||
people=await Users.job_people(
|
||||
self.session,
|
||||
JobPosts.recruiter_ids_of(job),
|
||||
[job.hiring_manager_id],
|
||||
)
|
||||
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[job.id])
|
||||
job_payload=serialize_job_row(
|
||||
job,
|
||||
people=people,
|
||||
applicant_count=counts.get(str(job.id),0),
|
||||
)
|
||||
|
||||
rows=await AtsResults.latest_per_candidate_for_job(self.session,job.id,search,top,limit,skip)
|
||||
total=await AtsResults.count_suggested_for_job(self.session,job.id,search)
|
||||
emails=[user_email or form_email for row,_,user_email,_,form_email,candidate in rows if candidate is None]
|
||||
by_email=await Candidates.latest_completed_for_job_by_emails(self.session,job.id,emails)
|
||||
candidates=[]
|
||||
for row,user_name,user_email,form_name,form_email,candidate in rows:
|
||||
email=user_email or form_email
|
||||
scored=candidate or by_email.get((email or "").strip().lower())
|
||||
candidates.append(serialize_suggested_candidate(
|
||||
row,
|
||||
name=user_name or form_name,
|
||||
email=email,
|
||||
candidate=scored,
|
||||
source=suggested_source(row.inbox_id,row.form_data_id,scored.source if scored else None),
|
||||
optional_matched=optional_skill_hits(job.optional_skills,scored.matched_keywords if scored else []),
|
||||
))
|
||||
return {"job":job_payload,**suggested_summary(candidates),"total":total,"candidates":candidates}
|
||||
|
||||
async def _job_row(self,row):
|
||||
people=await Users.job_people(
|
||||
names=await Users.names_by_ids(
|
||||
self.session,
|
||||
JobPosts.recruiter_ids_of(row),
|
||||
[row.hiring_manager_id],
|
||||
JobPosts.recruiter_ids_of(row)+[row.hiring_manager_id],
|
||||
)
|
||||
return serialize_job_row(
|
||||
row,
|
||||
names=names,
|
||||
hiring_manager_name=names.get(str(row.hiring_manager_id)),
|
||||
)
|
||||
return serialize_job_row(row,people=job_people_of(row,people))
|
||||
|
||||
async def _require_department(self,department_id):
|
||||
from department.models import Department
|
||||
department=await Department.get_by_id(self.session,department_id)
|
||||
if not department:
|
||||
raise HTTPException(status_code=404,detail="Department not found")
|
||||
return department
|
||||
|
||||
async def update_job(self,job_post_id,payload,current_user):
|
||||
if not current_user:
|
||||
|
|
@ -505,16 +444,6 @@ class JobPost:
|
|||
fields["salary"]=str(high)
|
||||
if "department" in fields and fields["department"] is None:
|
||||
fields["department"]=""
|
||||
if "department_id" in payload:
|
||||
if payload.get("department_id"):
|
||||
department=await self._require_department(payload.get("department_id"))
|
||||
fields["department_id"]=department.id
|
||||
fields["department_ref"]=department
|
||||
fields["department"]=department.name
|
||||
else:
|
||||
fields["department_id"]=None
|
||||
fields["department_ref"]=None
|
||||
fields["department"]=""
|
||||
|
||||
assignment=Assignment(self.session)
|
||||
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ from talent.app import router as talent_router
|
|||
from candidate_forms.app import router as candidate_forms_router
|
||||
from g_sheet.app import router as g_sheet_router
|
||||
from s3.app import router as s3_router
|
||||
from department.app import router as department_router
|
||||
from verification_check.app import router as verification_check_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
|
||||
logger=logging.getLogger("main")
|
||||
|
|
@ -138,5 +136,3 @@ app.include_router(talent_router)
|
|||
app.include_router(candidate_forms_router)
|
||||
app.include_router(g_sheet_router)
|
||||
app.include_router(s3_router)
|
||||
app.include_router(department_router)
|
||||
app.include_router(verification_check_router)
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
-- 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));
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
-- 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 $$;
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
-- 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));
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
-- 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 $$;
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
-- 042_verification_check.sql
|
||||
-- Annexure E - Employment Verification Check Form as its own table
|
||||
-- (backend/verification_check/models.py, VerificationCheck). One row per referee
|
||||
-- call, so a candidate can have several. recruiter_id and candidate_id both
|
||||
-- point at users.id (recruiters and candidates are users in this system);
|
||||
-- created_by is the logged-in user who filled the form.
|
||||
--
|
||||
-- The table name is mixed case at the user's request, so it must stay quoted
|
||||
-- ("Verification_Check") in any raw SQL. Index / FK names follow the db_setup
|
||||
-- NAMING_CONVENTION so a dev DB that autogenerated first is a no-op.
|
||||
--
|
||||
-- Idempotent, applied at startup by alembic_setup.run_manual_sql(). Needed
|
||||
-- because prod boots with DB_AUTOGENERATE=false and never autogenerates tables.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE t.typname = 'rehireeligibility' AND n.nspname = 'app'
|
||||
) THEN
|
||||
CREATE TYPE app.rehireeligibility AS ENUM ('yes', 'no', 'conditional');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app."Verification_Check" (
|
||||
id uuid PRIMARY KEY,
|
||||
recruiter_id uuid,
|
||||
candidate_id uuid,
|
||||
position_applied varchar,
|
||||
department_id uuid,
|
||||
department varchar,
|
||||
date_time timestamptz,
|
||||
confirmed_job_title varchar,
|
||||
confirmed_from date,
|
||||
confirmed_to date,
|
||||
reason_for_leaving varchar,
|
||||
technical_knowledge_rating integer,
|
||||
technical_knowledge_notes varchar,
|
||||
reliability_rating integer,
|
||||
reliability_notes varchar,
|
||||
communication_rating integer,
|
||||
communication_notes varchar,
|
||||
problem_solving_rating integer,
|
||||
problem_solving_notes varchar,
|
||||
strengths varchar,
|
||||
areas_for_growth varchar,
|
||||
rehire_eligibility app.rehireeligibility,
|
||||
rehire_comments varchar,
|
||||
referee_full_name varchar,
|
||||
referee_current_title varchar,
|
||||
referee_company varchar,
|
||||
referee_phone varchar,
|
||||
referee_email varchar,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
is_deleted boolean NOT NULL DEFAULT false,
|
||||
CONSTRAINT "fk_Verification_Check_recruiter_id_users"
|
||||
FOREIGN KEY (recruiter_id) REFERENCES app.users (id),
|
||||
CONSTRAINT "fk_Verification_Check_candidate_id_users"
|
||||
FOREIGN KEY (candidate_id) REFERENCES app.users (id),
|
||||
CONSTRAINT "fk_Verification_Check_department_id_departments"
|
||||
FOREIGN KEY (department_id) REFERENCES app.departments (id),
|
||||
CONSTRAINT "fk_Verification_Check_created_by_users"
|
||||
FOREIGN KEY (created_by) REFERENCES app.users (id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "ix_Verification_Check_recruiter_id"
|
||||
ON app."Verification_Check" (recruiter_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "ix_Verification_Check_candidate_id"
|
||||
ON app."Verification_Check" (candidate_id);
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
-- 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,10 +208,17 @@ async def system_admin_ids(session):
|
|||
|
||||
async def job_recruiter_ids(session, job):
|
||||
"""Recruiters currently linked to the job post.
|
||||
|
||||
Uses the live pointer (current_recruiter_id), the JSON list
|
||||
(current_recruiter_ids), and open job_assignments rows with
|
||||
assignment_role=primary_recruiter.
|
||||
"""
|
||||
ids = set()
|
||||
if job is None:
|
||||
return ids
|
||||
uid = _as_uuid(getattr(job, "current_recruiter_id", None))
|
||||
if uid is not None:
|
||||
ids.add(uid)
|
||||
for raw in getattr(job, "current_recruiter_ids", None) or []:
|
||||
extra = _as_uuid(raw)
|
||||
if extra is not None:
|
||||
|
|
|
|||
|
|
@ -1,265 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
"""Unit tests for the job-profile helpers in job/job_post/plugins.py — pure functions only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from job.job_post import plugins
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- suggested_source
|
||||
|
||||
def test_inbox_score_is_email_sourced_even_with_a_candidates_row():
|
||||
assert plugins.suggested_source(42, None, "upload") == "inbox"
|
||||
|
||||
|
||||
def test_form_score_is_form_sourced():
|
||||
assert plugins.suggested_source(None, "f-1", None) == "form"
|
||||
|
||||
|
||||
def test_upload_score_uses_the_candidates_row_source():
|
||||
assert plugins.suggested_source(None, None, "bank") == "bank"
|
||||
assert plugins.suggested_source(None, None, None) == "upload"
|
||||
assert plugins.suggested_source(None, None, " ") == "upload"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- optional_skill_hits
|
||||
|
||||
def test_exact_match_ignores_case_and_punctuation():
|
||||
assert plugins.optional_skill_hits(["FMCG Background", "Arabic"], ["fmcg-background"]) == ["FMCG Background"]
|
||||
|
||||
|
||||
def test_keyword_inside_skill_counts_on_word_boundaries():
|
||||
assert plugins.optional_skill_hits(["CRM (Salesforce)"], ["Salesforce"]) == ["CRM (Salesforce)"]
|
||||
|
||||
|
||||
def test_skill_inside_keyword_counts():
|
||||
assert plugins.optional_skill_hits(["Arabic"], ["Arabic language"]) == ["Arabic"]
|
||||
|
||||
|
||||
def test_partial_words_do_not_count():
|
||||
assert plugins.optional_skill_hits(["Java"], ["JavaScript"]) == []
|
||||
|
||||
|
||||
def test_hits_keep_job_order_and_skip_duplicates():
|
||||
hits = plugins.optional_skill_hits(["Travel", "Arabic", "Arabic"], ["arabic", "travel"])
|
||||
assert hits == ["Travel", "Arabic"]
|
||||
|
||||
|
||||
def test_no_optional_skills_or_keywords_is_empty():
|
||||
assert plugins.optional_skill_hits([], ["Python"]) == []
|
||||
assert plugins.optional_skill_hits(["Python"], None) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- suggested_summary
|
||||
|
||||
def test_summary_counts_bands_and_top_match():
|
||||
candidates = [
|
||||
{"band": "Strong Match", "match_score": 91},
|
||||
{"band": "Strong Match", "match_score": 85},
|
||||
{"band": "Potential Match", "match_score": 70},
|
||||
{"band": "Weak Match", "match_score": 40},
|
||||
{"band": None, "match_score": None},
|
||||
]
|
||||
summary = plugins.suggested_summary(candidates)
|
||||
assert summary["suggested"] == 5
|
||||
assert summary["top_match"] == 2
|
||||
assert summary["top_score"] == 91
|
||||
assert summary["bands"] == {"Strong Match": 2, "Potential Match": 1, "Weak Match": 1}
|
||||
|
||||
|
||||
def test_empty_summary():
|
||||
assert plugins.suggested_summary([]) == {
|
||||
"suggested": 0,
|
||||
"top_match": 0,
|
||||
"top_score": None,
|
||||
"bands": {"Strong Match": 0, "Potential Match": 0, "Weak Match": 0},
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import Field, Relationship, SQLModel, select
|
||||
|
||||
from role.models import EnumRoles, Roles
|
||||
from role.models import Roles
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module
|
||||
|
|
@ -31,6 +31,11 @@ class Users(SQLModel, table=True):
|
|||
role: Roles | None = Relationship(back_populates="users",
|
||||
sa_relationship_kwargs={"lazy": "selectin"}
|
||||
)
|
||||
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
|
||||
# user row once per post. Without an explicit strategy the default is a lazy load,
|
||||
# which raises MissingGreenlet the moment anything touches it under asyncio.
|
||||
# foreign_keys must match the other side: job_posts also has current_recruiter_id
|
||||
# and hiring_manager_id into this table, so this relation has to say created_by.
|
||||
job_posts: List[JobPosts] = Relationship(
|
||||
back_populates="user",
|
||||
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
|
||||
|
|
@ -154,12 +159,8 @@ class Users(SQLModel, table=True):
|
|||
return [row[0] for row in result.all()]
|
||||
|
||||
@classmethod
|
||||
async def names_by_ids(cls, session: AsyncSession, user_ids,search=None,top=None,limit=None) -> dict[str, str]:
|
||||
"""Resolve {user_id: name} in a single query, whatever role those ids hold.
|
||||
|
||||
Shared by departments, offers, history, notifications and the job payloads,
|
||||
so it stays role-agnostic: a read-time role filter cannot fix bad data, it
|
||||
only makes names disappear. Role is enforced on write by require_role.
|
||||
async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
||||
"""Resolve {user_id: name} in a single query.
|
||||
|
||||
COLUMN select, not the Users entity: `select(cls)` would pull the five
|
||||
selectin relations (role, job_posts, inbox, feedback, notes) for a
|
||||
|
|
@ -168,49 +169,11 @@ class Users(SQLModel, table=True):
|
|||
uids = {u for u in (user_ids or []) if u}
|
||||
if not uids:
|
||||
return {}
|
||||
statement =(
|
||||
result = await session.execute(
|
||||
select(cls.id, cls.name).where(cls.id.in_(uids))
|
||||
)
|
||||
if search:
|
||||
statement = statement.where(cls.name.ilike(f"%{search}%"))
|
||||
if top:
|
||||
statement = statement.limit(top)
|
||||
if limit:
|
||||
statement = statement.limit(limit)
|
||||
result = await session.execute(statement)
|
||||
return {str(uid): name for uid, name in result.all()}
|
||||
|
||||
@classmethod
|
||||
async def job_people(cls, session: AsyncSession, recruiter_ids, manager_ids=None) -> dict[str, dict[str, str]]:
|
||||
"""{"recruiters": {id: name}, "hiring_manager": {id: name}} — the two job
|
||||
ownership roles resolved apart, one query each.
|
||||
|
||||
They are different roles on a job post, so they never share a container.
|
||||
The manager side is role-checked (hiring_manager, not deleted) because it
|
||||
is a single stable owner; recruiters are validated on write. Both sides
|
||||
take a list, so one call serves a whole page of jobs.
|
||||
"""
|
||||
data: dict[str, dict[str, str]] = {"recruiters": {}, "hiring_manager": {}}
|
||||
|
||||
rids = {u for u in (recruiter_ids or []) if u}
|
||||
if rids:
|
||||
result = await session.execute(select(cls.id, cls.name).where(cls.id.in_(rids)))
|
||||
data["recruiters"] = {str(uid): name for uid, name in result.all()}
|
||||
|
||||
mids = {u for u in (manager_ids or []) if u}
|
||||
if mids:
|
||||
result = await session.execute(
|
||||
select(cls.id, cls.name)
|
||||
.join(Roles, Roles.id == cls.role_id)
|
||||
.where(
|
||||
cls.id.in_(mids),
|
||||
Roles.role_name == EnumRoles.HIRING_MANAGER.value,
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
data["hiring_manager"] = {str(uid): name for uid, name in result.all()}
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
async def get_by_ids(cls, session: AsyncSession, ids):
|
||||
"""Users with role selectin-loaded. UUID keys so callers can map by row.assignee_id."""
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ class PermissionModule(str, Enum):
|
|||
JOBS = "jobs"
|
||||
CANDIDATES = "candidates"
|
||||
PIPELINE = "pipeline"
|
||||
DEPARTMENT = "department"
|
||||
INTERVIEWS = "interviews"
|
||||
ASSESSMENTS = "assessments"
|
||||
OFFERS = "offers"
|
||||
|
|
@ -72,16 +71,6 @@ class PermissionTag(str, Enum):
|
|||
DASHBOARD_EXPORT = "dashboard.export"
|
||||
DASHBOARD_MANAGE = "dashboard.manage"
|
||||
DASHBOARD_CONFIGURE = "dashboard.configure"
|
||||
|
||||
DEPARTMENT_VIEW = "department.view"
|
||||
DEPARTMENT_CREATE = "department.create"
|
||||
DEPARTMENT_EDIT = "department.edit"
|
||||
DEPARTMENT_DELETE = "department.delete"
|
||||
DEPARTMENT_APPROVE = "department.approve"
|
||||
DEPARTMENT_EXPORT = "department.export"
|
||||
DEPARTMENT_MANAGE = "department.manage"
|
||||
DEPARTMENT_CONFIGURE = "department.configure"
|
||||
|
||||
INBOX_VIEW = "inbox.view"
|
||||
INBOX_CREATE = "inbox.create"
|
||||
INBOX_EDIT = "inbox.edit"
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db_setup import get_session
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
from verification_check.enums import (
|
||||
CompetencyEvaluation,
|
||||
EmploymentVerification,
|
||||
QualitativeEvaluation,
|
||||
RecordOverview,
|
||||
RefereeDetails,
|
||||
)
|
||||
from verification_check.views import VerificationCheckForm
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class VerificationCheckCreate(BaseModel):
|
||||
recruiter_id:uuid.UUID
|
||||
candidate_id:uuid.UUID
|
||||
record_overview:Optional[RecordOverview]
|
||||
employment_verification:Optional[EmploymentVerification]
|
||||
competency_evaluation:Optional[CompetencyEvaluation]
|
||||
qualitative_evaluation:Optional[QualitativeEvaluation]
|
||||
referee:Optional[RefereeDetails]
|
||||
|
||||
|
||||
class VerificationCheckUpdate(BaseModel):
|
||||
recruiter_id:Optional[uuid.UUID]=None
|
||||
candidate_id:Optional[uuid.UUID]=None
|
||||
record_overview:Optional[RecordOverview]=None
|
||||
employment_verification:Optional[EmploymentVerification]=None
|
||||
competency_evaluation:Optional[CompetencyEvaluation]=None
|
||||
qualitative_evaluation:Optional[QualitativeEvaluation]=None
|
||||
referee:Optional[RefereeDetails]=None
|
||||
|
||||
|
||||
@router.get("/verification-check/fetch")
|
||||
async def fetch_verification_check(
|
||||
current_user:dict=Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
form_id:str=Query(None),
|
||||
candidate_id:str=Query(None),
|
||||
session:AsyncSession=Depends(get_session),
|
||||
):
|
||||
"""One row by form_id, else the list (optionally one candidate's referees).
|
||||
Admins see every row; other roles stay scoped to created_by."""
|
||||
try:
|
||||
service=VerificationCheckForm(session=session)
|
||||
data=await service.get_form_by_id(form_id,candidate_id,current_user)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/verification-check/create")
|
||||
async def create_verification_check(
|
||||
payload: VerificationCheckCreate,
|
||||
current_user:dict=Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||
session:AsyncSession=Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = VerificationCheckForm(session=session)
|
||||
data = await service.create_form(payload.model_dump(exclude_unset=True), current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/verification-check/update")
|
||||
async def update_verification_check(
|
||||
payload: VerificationCheckUpdate,
|
||||
current_user:dict=Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||
form_id:str=Query(...),
|
||||
session:AsyncSession=Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=VerificationCheckForm(session=session)
|
||||
data=await service.update_form(form_id,payload.model_dump(exclude_unset=True),current_user)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/verification-check/delete")
|
||||
async def delete_verification_check(
|
||||
current_user:dict=Depends(require_permission(PermissionTag.CANDIDATES_DELETE)),
|
||||
form_id:str=Query(...),
|
||||
session:AsyncSession=Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=VerificationCheckForm(session=session)
|
||||
data=await service.delete_form(form_id,current_user)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
from enum import Enum
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import date, datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class CompetencyRating(int, Enum):
|
||||
NEEDS_IMPROVEMENT = 1
|
||||
SATISFACTORY = 2
|
||||
GOOD = 3
|
||||
EXCELLENT = 4
|
||||
|
||||
|
||||
class ReHireEligibility(str, Enum):
|
||||
YES = "yes"
|
||||
NO = "no"
|
||||
CONDITIONAL = "conditional"
|
||||
|
||||
|
||||
# SECTION 1: INFORMATION & RECORD OVERVIEW
|
||||
class RecordOverview(BaseModel):
|
||||
position_applied:Optional[str]=None
|
||||
department_id:Optional[uuid.UUID]=None
|
||||
department:Optional[str]=None
|
||||
date_time:Optional[datetime]=None
|
||||
|
||||
|
||||
# SECTION 2: EMPLOYMENT VERIFICATION & DUTIES
|
||||
class EmploymentVerification(BaseModel):
|
||||
confirmed_job_title:Optional[str]=None
|
||||
confirmed_from:Optional[date]=None
|
||||
confirmed_to:Optional[date]=None
|
||||
reason_for_leaving:Optional[str]=None
|
||||
|
||||
|
||||
# SECTION 3: one row of the competency grid (1-4 tick + Notes column)
|
||||
class CompetencyScore(BaseModel):
|
||||
rating:Optional[CompetencyRating]=None
|
||||
notes:Optional[str]=None
|
||||
|
||||
|
||||
class CompetencyEvaluation(BaseModel):
|
||||
technical_knowledge:Optional[CompetencyScore]=None
|
||||
reliability:Optional[CompetencyScore]=None
|
||||
communication:Optional[CompetencyScore]=None
|
||||
problem_solving:Optional[CompetencyScore]=None
|
||||
|
||||
|
||||
# SECTION 4: QUALITATIVE EVALUATION & KEY INSIGHTS
|
||||
class QualitativeEvaluation(BaseModel):
|
||||
strengths:Optional[str]=None
|
||||
areas_for_growth:Optional[str]=None
|
||||
rehire_eligibility:Optional[ReHireEligibility]=None
|
||||
rehire_comments:Optional[str]=None
|
||||
|
||||
|
||||
# SECTION 5: REFEREE CONTACT & RELATIONSHIP VERIFICATION
|
||||
class RefereeDetails(BaseModel):
|
||||
full_name:Optional[str]=None
|
||||
current_title:Optional[str]=None
|
||||
company:Optional[str]=None
|
||||
phone:Optional[str]=None
|
||||
email:Optional[str]=None
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
import uuid
|
||||
from datetime import datetime, date as Date, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Enum as SAEnum
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
||||
from verification_check.enums import ReHireEligibility
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class VerificationCheck(SQLModel, table=True):
|
||||
"""Annexure E - Employment Verification Check Form. One row per referee call,
|
||||
so a candidate can hold several. recruiter_id / candidate_id are both users.id."""
|
||||
|
||||
__tablename__ = "Verification_Check"
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
|
||||
recruiter_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id", index=True)
|
||||
candidate_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id", index=True)
|
||||
|
||||
position_applied: Optional[str] = None
|
||||
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id")
|
||||
department: Optional[str] = None
|
||||
date_time: Optional[datetime] = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
|
||||
confirmed_job_title: Optional[str] = None
|
||||
confirmed_from: Optional[Date] = None
|
||||
confirmed_to: Optional[Date] = None
|
||||
reason_for_leaving: Optional[str] = None
|
||||
|
||||
technical_knowledge_rating: Optional[int] = None
|
||||
technical_knowledge_notes: Optional[str] = None
|
||||
reliability_rating: Optional[int] = None
|
||||
reliability_notes: Optional[str] = None
|
||||
communication_rating: Optional[int] = None
|
||||
communication_notes: Optional[str] = None
|
||||
problem_solving_rating: Optional[int] = None
|
||||
problem_solving_notes: Optional[str] = None
|
||||
|
||||
strengths: Optional[str] = None
|
||||
areas_for_growth: Optional[str] = None
|
||||
rehire_eligibility: Optional[ReHireEligibility] = Field(
|
||||
default=None,
|
||||
sa_type=SAEnum(
|
||||
ReHireEligibility,
|
||||
name="rehireeligibility",
|
||||
schema="app",
|
||||
native_enum=True,
|
||||
values_callable=lambda enum: [member.value for member in enum],
|
||||
),
|
||||
)
|
||||
rehire_comments: Optional[str] = None
|
||||
|
||||
referee_full_name: Optional[str] = None
|
||||
referee_current_title: Optional[str] = None
|
||||
referee_company: Optional[str] = None
|
||||
referee_phone: Optional[str] = None
|
||||
referee_email: Optional[str] = None
|
||||
|
||||
created_by: Optional[uuid.UUID] = Field(foreign_key="users.id")
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
is_deleted: bool = Field(default=False)
|
||||
|
||||
@classmethod
|
||||
async def get_form_by_id(cls, session: AsyncSession, record_id=None, created_by=None, candidate_id=None):
|
||||
qry = select(cls).where(cls.is_deleted == False) # noqa: E712
|
||||
if created_by is not None:
|
||||
qry = qry.where(cls.created_by == created_by)
|
||||
if candidate_id is not None:
|
||||
qry = qry.where(cls.candidate_id == candidate_id)
|
||||
if record_id not in (None, ""):
|
||||
try:
|
||||
uid = uuid.UUID(str(record_id))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
qry = qry.where(cls.id == uid)
|
||||
result = await session.execute(qry)
|
||||
return result.scalars().first()
|
||||
result = await session.execute(qry.order_by(cls.created_at.desc(), cls.id.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def insert_form(cls, session: AsyncSession, fields: dict):
|
||||
overview = fields.get("record_overview") if fields.get("record_overview") else {}
|
||||
employment = fields.get("employment_verification") if fields.get("employment_verification") else {}
|
||||
competency = fields.get("competency_evaluation") if fields.get("competency_evaluation") else {}
|
||||
technical = competency.get("technical_knowledge") if competency.get("technical_knowledge") else {}
|
||||
reliability = competency.get("reliability") if competency.get("reliability") else {}
|
||||
communication = competency.get("communication") if competency.get("communication") else {}
|
||||
problem_solving = competency.get("problem_solving") if competency.get("problem_solving") else {}
|
||||
qualitative = fields.get("qualitative_evaluation") if fields.get("qualitative_evaluation") else {}
|
||||
referee = fields.get("referee") if fields.get("referee") else {}
|
||||
row = cls(
|
||||
recruiter_id=fields.get("recruiter_id") if fields.get("recruiter_id") else None,
|
||||
candidate_id=fields.get("candidate_id") if fields.get("candidate_id") else None,
|
||||
position_applied=overview.get("position_applied") if overview.get("position_applied") else None,
|
||||
department_id=overview.get("department_id") if overview.get("department_id") else None,
|
||||
department=overview.get("department") if overview.get("department") else None,
|
||||
date_time=overview.get("date_time") if overview.get("date_time") else None,
|
||||
confirmed_job_title=employment.get("confirmed_job_title") if employment.get("confirmed_job_title") else None,
|
||||
confirmed_from=employment.get("confirmed_from") if employment.get("confirmed_from") else None,
|
||||
confirmed_to=employment.get("confirmed_to") if employment.get("confirmed_to") else None,
|
||||
reason_for_leaving=employment.get("reason_for_leaving") if employment.get("reason_for_leaving") else None,
|
||||
technical_knowledge_rating=int(technical.get("rating")) if technical.get("rating") else None,
|
||||
technical_knowledge_notes=technical.get("notes") if technical.get("notes") else None,
|
||||
reliability_rating=int(reliability.get("rating")) if reliability.get("rating") else None,
|
||||
reliability_notes=reliability.get("notes") if reliability.get("notes") else None,
|
||||
communication_rating=int(communication.get("rating")) if communication.get("rating") else None,
|
||||
communication_notes=communication.get("notes") if communication.get("notes") else None,
|
||||
problem_solving_rating=int(problem_solving.get("rating")) if problem_solving.get("rating") else None,
|
||||
problem_solving_notes=problem_solving.get("notes") if problem_solving.get("notes") else None,
|
||||
strengths=qualitative.get("strengths") if qualitative.get("strengths") else None,
|
||||
areas_for_growth=qualitative.get("areas_for_growth") if qualitative.get("areas_for_growth") else None,
|
||||
rehire_eligibility=ReHireEligibility(qualitative.get("rehire_eligibility")) if qualitative.get("rehire_eligibility") else None,
|
||||
rehire_comments=qualitative.get("rehire_comments") if qualitative.get("rehire_comments") else None,
|
||||
referee_full_name=referee.get("full_name") if referee.get("full_name") else None,
|
||||
referee_current_title=referee.get("current_title") if referee.get("current_title") else None,
|
||||
referee_company=referee.get("company") if referee.get("company") else None,
|
||||
referee_phone=referee.get("phone") if referee.get("phone") else None,
|
||||
referee_email=referee.get("email") if referee.get("email") else None,
|
||||
created_by=fields.get("created_by") if fields.get("created_by") else None,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_form_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_form(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_form_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
if "recruiter_id" in fields:
|
||||
row.recruiter_id = fields.get("recruiter_id") if fields.get("recruiter_id") else None
|
||||
if "candidate_id" in fields:
|
||||
row.candidate_id = fields.get("candidate_id") if fields.get("candidate_id") else None
|
||||
if "record_overview" in fields:
|
||||
overview = fields.get("record_overview") if fields.get("record_overview") else {}
|
||||
if "position_applied" in overview:
|
||||
row.position_applied = overview.get("position_applied") if overview.get("position_applied") else None
|
||||
if "department_id" in overview:
|
||||
row.department_id = overview.get("department_id") if overview.get("department_id") else None
|
||||
if "department" in overview:
|
||||
row.department = overview.get("department") if overview.get("department") else None
|
||||
if "date_time" in overview:
|
||||
row.date_time = overview.get("date_time") if overview.get("date_time") else None
|
||||
if "employment_verification" in fields:
|
||||
employment = fields.get("employment_verification") if fields.get("employment_verification") else {}
|
||||
if "confirmed_job_title" in employment:
|
||||
row.confirmed_job_title = employment.get("confirmed_job_title") if employment.get("confirmed_job_title") else None
|
||||
if "confirmed_from" in employment:
|
||||
row.confirmed_from = employment.get("confirmed_from") if employment.get("confirmed_from") else None
|
||||
if "confirmed_to" in employment:
|
||||
row.confirmed_to = employment.get("confirmed_to") if employment.get("confirmed_to") else None
|
||||
if "reason_for_leaving" in employment:
|
||||
row.reason_for_leaving = employment.get("reason_for_leaving") if employment.get("reason_for_leaving") else None
|
||||
if "competency_evaluation" in fields:
|
||||
competency = fields.get("competency_evaluation") if fields.get("competency_evaluation") else {}
|
||||
if "technical_knowledge" in competency:
|
||||
technical = competency.get("technical_knowledge") if competency.get("technical_knowledge") else {}
|
||||
if "rating" in technical:
|
||||
row.technical_knowledge_rating = int(technical.get("rating")) if technical.get("rating") else None
|
||||
if "notes" in technical:
|
||||
row.technical_knowledge_notes = technical.get("notes") if technical.get("notes") else None
|
||||
if "reliability" in competency:
|
||||
reliability = competency.get("reliability") if competency.get("reliability") else {}
|
||||
if "rating" in reliability:
|
||||
row.reliability_rating = int(reliability.get("rating")) if reliability.get("rating") else None
|
||||
if "notes" in reliability:
|
||||
row.reliability_notes = reliability.get("notes") if reliability.get("notes") else None
|
||||
if "communication" in competency:
|
||||
communication = competency.get("communication") if competency.get("communication") else {}
|
||||
if "rating" in communication:
|
||||
row.communication_rating = int(communication.get("rating")) if communication.get("rating") else None
|
||||
if "notes" in communication:
|
||||
row.communication_notes = communication.get("notes") if communication.get("notes") else None
|
||||
if "problem_solving" in competency:
|
||||
problem_solving = competency.get("problem_solving") if competency.get("problem_solving") else {}
|
||||
if "rating" in problem_solving:
|
||||
row.problem_solving_rating = int(problem_solving.get("rating")) if problem_solving.get("rating") else None
|
||||
if "notes" in problem_solving:
|
||||
row.problem_solving_notes = problem_solving.get("notes") if problem_solving.get("notes") else None
|
||||
if "qualitative_evaluation" in fields:
|
||||
qualitative = fields.get("qualitative_evaluation") if fields.get("qualitative_evaluation") else {}
|
||||
if "strengths" in qualitative:
|
||||
row.strengths = qualitative.get("strengths") if qualitative.get("strengths") else None
|
||||
if "areas_for_growth" in qualitative:
|
||||
row.areas_for_growth = qualitative.get("areas_for_growth") if qualitative.get("areas_for_growth") else None
|
||||
if "rehire_eligibility" in qualitative:
|
||||
row.rehire_eligibility = ReHireEligibility(qualitative.get("rehire_eligibility")) if qualitative.get("rehire_eligibility") else None
|
||||
if "rehire_comments" in qualitative:
|
||||
row.rehire_comments = qualitative.get("rehire_comments") if qualitative.get("rehire_comments") else None
|
||||
if "referee" in fields:
|
||||
referee = fields.get("referee") if fields.get("referee") else {}
|
||||
if "full_name" in referee:
|
||||
row.referee_full_name = referee.get("full_name") if referee.get("full_name") else None
|
||||
if "current_title" in referee:
|
||||
row.referee_current_title = referee.get("current_title") if referee.get("current_title") else None
|
||||
if "company" in referee:
|
||||
row.referee_company = referee.get("company") if referee.get("company") else None
|
||||
if "phone" in referee:
|
||||
row.referee_phone = referee.get("phone") if referee.get("phone") else None
|
||||
if "email" in referee:
|
||||
row.referee_email = referee.get("email") if referee.get("email") else None
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def soft_delete_form(cls, session: AsyncSession, record_id):
|
||||
row = await cls.get_form_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
row.is_deleted = True
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row
|
||||
|
||||
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
import department.models as _department_models # noqa: E402, F401
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
def _date(value):
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _enum(value):
|
||||
if value is None:
|
||||
return None
|
||||
return getattr(value, "value", value)
|
||||
|
||||
|
||||
def serialize_verification_check(row, names=None) -> dict:
|
||||
"""`names` is {user_id: name} from one batched Users.names_by_ids lookup in
|
||||
views, so the recruiter / candidate search dropdowns can preselect on edit."""
|
||||
names = names or {}
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"recruiter_id": str(row.recruiter_id) if row.recruiter_id else None,
|
||||
"recruiter_name": names.get(str(row.recruiter_id)) if row.recruiter_id else None,
|
||||
"candidate_id": str(row.candidate_id) if row.candidate_id else None,
|
||||
"candidate_name": names.get(str(row.candidate_id)) if row.candidate_id else None,
|
||||
"record_overview": {
|
||||
"position_applied": row.position_applied,
|
||||
"department_id": str(row.department_id) if row.department_id else None,
|
||||
"department": row.department,
|
||||
"date_time": _date(row.date_time),
|
||||
},
|
||||
"employment_verification": {
|
||||
"confirmed_job_title": row.confirmed_job_title,
|
||||
"confirmed_from": _date(row.confirmed_from),
|
||||
"confirmed_to": _date(row.confirmed_to),
|
||||
"reason_for_leaving": row.reason_for_leaving,
|
||||
},
|
||||
"competency_evaluation": {
|
||||
"technical_knowledge": {
|
||||
"rating": row.technical_knowledge_rating,
|
||||
"notes": row.technical_knowledge_notes,
|
||||
},
|
||||
"reliability": {
|
||||
"rating": row.reliability_rating,
|
||||
"notes": row.reliability_notes,
|
||||
},
|
||||
"communication": {
|
||||
"rating": row.communication_rating,
|
||||
"notes": row.communication_notes,
|
||||
},
|
||||
"problem_solving": {
|
||||
"rating": row.problem_solving_rating,
|
||||
"notes": row.problem_solving_notes,
|
||||
},
|
||||
},
|
||||
"qualitative_evaluation": {
|
||||
"strengths": row.strengths,
|
||||
"areas_for_growth": row.areas_for_growth,
|
||||
"rehire_eligibility": _enum(row.rehire_eligibility),
|
||||
"rehire_comments": row.rehire_comments,
|
||||
},
|
||||
"referee": {
|
||||
"full_name": row.referee_full_name,
|
||||
"current_title": row.referee_current_title,
|
||||
"company": row.referee_company,
|
||||
"phone": row.referee_phone,
|
||||
"email": row.referee_email,
|
||||
},
|
||||
"created_by": str(row.created_by) if row.created_by else None,
|
||||
"created_by_name": names.get(str(row.created_by)) if row.created_by else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from candidate_forms.plugins import _as_uuid, _aware, _user_id
|
||||
from users.models import Users
|
||||
from users.permissions import is_admin
|
||||
from verification_check.models import VerificationCheck
|
||||
from verification_check.serializers import serialize_verification_check
|
||||
|
||||
|
||||
class VerificationCheckForm:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def _serialize_rows(self, rows):
|
||||
user_ids = set()
|
||||
for row in rows:
|
||||
user_ids |= {row.recruiter_id, row.candidate_id, row.created_by}
|
||||
names = await Users.names_by_ids(self.session, user_ids)
|
||||
return [serialize_verification_check(row, names) for row in rows]
|
||||
|
||||
async def create_form(self, payload, current_user):
|
||||
if payload.get("record_overview") and "date_time" in payload["record_overview"]:
|
||||
payload["record_overview"]["date_time"] = _aware(payload["record_overview"]["date_time"])
|
||||
payload["created_by"] = _user_id(current_user)
|
||||
row = await VerificationCheck.insert_form(self.session, payload)
|
||||
return (await self._serialize_rows([row]))[0]
|
||||
|
||||
async def update_form(self, form_id, payload, current_user):
|
||||
_user_id(current_user)
|
||||
row = await VerificationCheck.get_form_by_id(self.session, form_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Verification check not found")
|
||||
if not payload:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
if payload.get("record_overview") and "date_time" in payload["record_overview"]:
|
||||
payload["record_overview"]["date_time"] = _aware(payload["record_overview"]["date_time"])
|
||||
updated = await VerificationCheck.update_form(self.session, form_id, payload)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Verification check not found")
|
||||
return (await self._serialize_rows([updated]))[0]
|
||||
|
||||
async def get_form_by_id(self, form_id, candidate_id, current_user):
|
||||
created_by = None if is_admin(current_user) else _user_id(current_user)
|
||||
if form_id:
|
||||
row = await VerificationCheck.get_form_by_id(self.session, record_id=form_id, created_by=created_by)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Verification check not found")
|
||||
return (await self._serialize_rows([row]))[0]
|
||||
rows = await VerificationCheck.get_form_by_id(
|
||||
self.session, created_by=created_by, candidate_id=_as_uuid(candidate_id)
|
||||
)
|
||||
return await self._serialize_rows(rows)
|
||||
|
||||
async def delete_form(self, form_id, current_user):
|
||||
_user_id(current_user)
|
||||
row = await VerificationCheck.soft_delete_form(self.session, form_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Verification check not found")
|
||||
return {"id": str(row.id), "deleted": True}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
#!/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 ====="
|
||||
|
|
@ -1,353 +0,0 @@
|
|||
/**
|
||||
* 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,13 +8,6 @@ server {
|
|||
# CV / multipart uploads (MAX_PDF_SIZE_MB is 10; leave headroom for form fields).
|
||||
client_max_body_size 25m;
|
||||
|
||||
# Resolve backend-api through Docker's embedded DNS on each request instead of
|
||||
# once at startup. `docker compose up` recreates backend-api with a new IP;
|
||||
# a static upstream keeps the old one and every API call 502s until nginx restarts.
|
||||
# proxy_pass with a variable and no URI forwards the original request URI unchanged.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $backend_api http://backend-api:8000;
|
||||
|
||||
# Security headers on every response.
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
|
|
@ -30,7 +23,7 @@ server {
|
|||
|
||||
# SPA page roots that also prefix API calls — sub-path required.
|
||||
location ~ ^/(jobs|inbox|pipeline|tasks|assessments|offers|managers|analytics|notifications)/ {
|
||||
proxy_pass $backend_api;
|
||||
proxy_pass http://backend-api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
@ -44,8 +37,8 @@ server {
|
|||
}
|
||||
|
||||
# API-only prefixes (no SPA page at the bare path).
|
||||
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms|department)(/|$) {
|
||||
proxy_pass $backend_api;
|
||||
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms)(/|$) {
|
||||
proxy_pass http://backend-api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@
|
|||
"test:cvbank": "node cvbank.test.mjs",
|
||||
"test:browse": "node candidate-browse.test.mjs",
|
||||
"test:mobile": "node mobile.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"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ const SCREENS = {
|
|||
assessments: lazy(() => import('./screens/Assessments')),
|
||||
offers: lazy(() => import('./screens/Offers')),
|
||||
managers: lazy(() => import('./screens/Managers')),
|
||||
departments: lazy(() => import('./screens/Departments')),
|
||||
calendar: lazy(() => import('./screens/Calendar')),
|
||||
reports: lazy(() => import('./screens/Reports')),
|
||||
analytics: lazy(() => import('./screens/Analytics')),
|
||||
|
|
@ -55,7 +54,6 @@ function LegacyCandidateRedirect() {
|
|||
const to = `/candidate/${encodeURIComponent(userId)}`
|
||||
return <Navigate to={tab ? `${to}?tab=${encodeURIComponent(tab)}` : to} replace />
|
||||
}
|
||||
const JobProfile = lazy(() => import('./screens/JobProfile'))
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
|
|
@ -105,14 +103,6 @@ export default function App() {
|
|||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/job/:jobId"
|
||||
element={
|
||||
<RequireAuth permission="jobs.view">
|
||||
<JobProfile />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/candidates/:userId"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ import Notifications from '../screens/Notifications'
|
|||
import Rbac from '../screens/Rbac'
|
||||
import Settings from '../screens/Settings'
|
||||
import Help from '../screens/Help'
|
||||
import JobProfile from '../screens/JobProfile'
|
||||
|
||||
const SCREENS = {
|
||||
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
||||
|
|
@ -69,11 +68,6 @@ const PAGES = {
|
|||
'/auth/confirm-email': ConfirmEmail,
|
||||
}
|
||||
|
||||
// Detail pages live outside the ROUTES table (parameterized path), as in App.jsx.
|
||||
const DETAIL_PAGES = [
|
||||
{ pattern: '/job/:jobId', prefix: '/job/', Screen: JobProfile },
|
||||
]
|
||||
|
||||
export const ALL_ROUTES = [
|
||||
...Object.keys(PAGES),
|
||||
...TABLE.map((r) => `/${r.path}`),
|
||||
|
|
@ -118,21 +112,9 @@ export async function mountRoute(path, container) {
|
|||
})
|
||||
await settle()
|
||||
}
|
||||
// React tracks an input's value through the native setter; assigning
|
||||
// el.value directly is invisible to onChange, so go through the prototype.
|
||||
const type = async (el, value) => {
|
||||
if (!el) throw new Error('type: element not found')
|
||||
const win = el.ownerDocument.defaultView
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, 'value').set.call(el, value)
|
||||
el.dispatchEvent(new win.Event('input', { bubbles: true }))
|
||||
})
|
||||
await settle()
|
||||
}
|
||||
|
||||
return {
|
||||
settle,
|
||||
type,
|
||||
click,
|
||||
selectOption,
|
||||
html: () => container.innerHTML,
|
||||
|
|
@ -147,17 +129,15 @@ export async function mountRoute(path, container) {
|
|||
function routeTree(path) {
|
||||
const h = React.createElement
|
||||
const isAuth = path.startsWith('/auth/')
|
||||
const detail = DETAIL_PAGES.find((d) => path.startsWith(d.prefix))
|
||||
const def = TABLE.find((r) => `/${r.path}` === path.split('?')[0])
|
||||
const Screen = isAuth ? PAGES[path] : detail ? detail.Screen : SCREENS[def.path]
|
||||
const routePath = detail ? detail.pattern : path.split('?')[0]
|
||||
const def = TABLE.find((r) => `/${r.path}` === path)
|
||||
const Screen = isAuth ? PAGES[path] : SCREENS[def.path]
|
||||
|
||||
const inner = isAuth
|
||||
? h(Route, { path, element: h(Screen) })
|
||||
: h(
|
||||
Route,
|
||||
{ element: h(RequireAuth, null, h(AppLayout)) },
|
||||
h(Route, { path: routePath, element: h(Screen) }),
|
||||
h(Route, { path, element: h(Screen) }),
|
||||
)
|
||||
|
||||
return h(
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
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,24 +64,7 @@ function experienceLabel(min, max) {
|
|||
return `${min ?? max}+ years`
|
||||
}
|
||||
|
||||
/* Job ownership arrives in one of two shapes. serialize_job_row (GET /jobs/fetch,
|
||||
GET /jobs/profile/fetch) sends the two roles as separate {id: name} objects:
|
||||
|
||||
"recruiters": {"ed9e…": "Nida Khan"}, "hiring_manager": {"77aa…": "Amara Osei"}
|
||||
|
||||
serialize_job_post — the inbox / matching / picker payload — still sends the
|
||||
older flat keys (current_recruiter_ids, recruiter_names, recruiters as an
|
||||
ARRAY of {id, name}, hiring_manager_id / hiring_manager_name). Both are read
|
||||
here so one mapper serves every caller; the Array check is what tells the two
|
||||
`recruiters` shapes apart. */
|
||||
function recruiterMap(row) {
|
||||
const value = row?.recruiters
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : null
|
||||
}
|
||||
|
||||
function recruiterIdsFrom(row) {
|
||||
const map = recruiterMap(row)
|
||||
if (map) return Object.keys(map).map(String)
|
||||
const ids = Array.isArray(row?.current_recruiter_ids)
|
||||
? row.current_recruiter_ids.filter(Boolean).map(String)
|
||||
: []
|
||||
|
|
@ -90,8 +73,6 @@ function recruiterIdsFrom(row) {
|
|||
}
|
||||
|
||||
function recruiterNamesFrom(row) {
|
||||
const map = recruiterMap(row)
|
||||
if (map) return Object.values(map).filter(Boolean)
|
||||
if (Array.isArray(row?.recruiter_names) && row.recruiter_names.length) {
|
||||
return row.recruiter_names.filter(Boolean)
|
||||
}
|
||||
|
|
@ -101,29 +82,14 @@ function recruiterNamesFrom(row) {
|
|||
return row?.recruiter_name ? [row.recruiter_name] : []
|
||||
}
|
||||
|
||||
/** {id, name} of the hiring manager, from either payload shape. */
|
||||
function hiringManagerFrom(row) {
|
||||
const value = row?.hiring_manager
|
||||
if (value && typeof value === 'object') {
|
||||
const [id, name] = Object.entries(value)[0] ?? []
|
||||
if (id) return { id: String(id), name: name ?? null }
|
||||
}
|
||||
return {
|
||||
id: row?.hiring_manager_id ? String(row.hiring_manager_id) : null,
|
||||
name: row?.hiring_manager_name ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** API row -> what the Jobs table and detail modal render. */
|
||||
export function toJobView(row) {
|
||||
const recruiterIds = recruiterIdsFrom(row)
|
||||
const recruiterNames = recruiterNamesFrom(row)
|
||||
const manager = hiringManagerFrom(row)
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
department: row.department,
|
||||
departmentId: row.department_id || null,
|
||||
location: row.location,
|
||||
type: row.employment_type,
|
||||
vacancies: row.vacancies,
|
||||
|
|
@ -134,8 +100,8 @@ export function toJobView(row) {
|
|||
recruiterId: recruiterIds[0] || null,
|
||||
recruiterIds,
|
||||
recruiterNames,
|
||||
hiringManager: manager.name,
|
||||
hiringManagerId: manager.id,
|
||||
hiringManager: row.hiring_manager_name,
|
||||
hiringManagerId: row.hiring_manager_id,
|
||||
createdByName: row.created_by_name,
|
||||
applicantCount: row.applicant_count ?? 0,
|
||||
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.
|
||||
|
|
@ -211,46 +177,6 @@ export function setStatus(jobPostId, status) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Job profile page — GET /jobs/profile/fetch. One round trip: the requisition
|
||||
* row (same shape as /jobs/fetch, so toJobView applies), its suggested
|
||||
* candidates (newest ats_results row per person, best score first) and the
|
||||
* Suggested / Top Match header stats.
|
||||
*/
|
||||
export function fetchProfile(jobPostId, { search, top, limit, skip } = {}) {
|
||||
return request('/jobs/profile/fetch', {
|
||||
params: { job_post_id: jobPostId, search, top, limit, skip },
|
||||
})
|
||||
}
|
||||
|
||||
/** ats_results.band labels, best first — the backend's MATCH_BANDS. */
|
||||
export const MATCH_BANDS = ['Strong Match', 'Potential Match', 'Weak Match']
|
||||
|
||||
export const SUGGESTED_SOURCE_LABEL = { inbox: 'Email', form: 'Sheet Form', upload: 'Uploaded', bank: 'CV Bank' }
|
||||
|
||||
/** One suggested candidate -> what the job profile card renders. */
|
||||
export function toSuggestedView(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id || null,
|
||||
scoredCandidateId: row.scored_candidate_id || null,
|
||||
name: row.name || row.email || 'Unknown',
|
||||
email: row.email || null,
|
||||
currentTitle: row.current_title || null,
|
||||
currentCompany: row.current_company || null,
|
||||
experience: row.years_experience ?? null,
|
||||
score: row.match_score ?? null,
|
||||
band: row.band || null,
|
||||
matchedSkills: Array.isArray(row.matched_keywords) ? row.matched_keywords : [],
|
||||
missingSkills: Array.isArray(row.missing_keywords) ? row.missing_keywords : [],
|
||||
optionalMatched: Array.isArray(row.optional_matched) ? row.optional_matched : [],
|
||||
summary: row.summary || null,
|
||||
source: row.source || null,
|
||||
sourceLabel: SUGGESTED_SOURCE_LABEL[row.source] ?? row.source ?? '—',
|
||||
scoredAt: toDate(row.created_at),
|
||||
}
|
||||
}
|
||||
|
||||
/** Status-change audit for one requisition — GET /jobs/status-history/fetch. */
|
||||
export function listStatusHistory(jobPostId) {
|
||||
return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } })
|
||||
|
|
|
|||
|
|
@ -22,11 +22,6 @@ export function list() {
|
|||
return request('/forms/requisition/fetch')
|
||||
}
|
||||
|
||||
/** GET /forms/requisition/open-count — `{data:{open}}`: unlinked, or linked job still open. */
|
||||
export function countOpen() {
|
||||
return request('/forms/requisition/open-count')
|
||||
}
|
||||
|
||||
export function getById(formId) {
|
||||
return request('/forms/requisition/fetch', { params: { form_id: formId } })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ export const ROUTES = [
|
|||
{ path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' },
|
||||
{ path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' },
|
||||
{ path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: 'jobs.view' },
|
||||
{ path: 'departments', title: 'Departments', icon: 'layers', group: 'Hiring', permission: 'department.view', tag: 'NEW' },
|
||||
{ path: 'calendar', title: 'Calendar', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
|
||||
|
||||
// --- Insights ---
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
export const MODULES = [
|
||||
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
|
||||
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks',
|
||||
'talent', 'requisitions', 'department',
|
||||
'talent', 'requisitions',
|
||||
]
|
||||
|
||||
export const ACTIONS = [
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ export const qk = {
|
|||
list: (p = {}) => ['jobs', 'list', p],
|
||||
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
|
||||
statusHistory: (id) => ['jobs', 'status-history', id],
|
||||
profile: (id, p = {}) => ['jobs', 'profile', id, p],
|
||||
stats: (p = {}) => ['jobs', 'stats', p],
|
||||
},
|
||||
talent: {
|
||||
|
|
@ -144,17 +143,9 @@ export const qk = {
|
|||
requisitions: {
|
||||
all: () => ['requisitions'],
|
||||
list: () => ['requisitions', 'list'],
|
||||
openCount: () => ['requisitions', 'open-count'],
|
||||
detail: (id) => ['requisitions', 'detail', id],
|
||||
search: (q = '', jobPostId = null) => ['requisitions', 'search', q, jobPostId || null],
|
||||
},
|
||||
departments: {
|
||||
all: () => ['departments'],
|
||||
list: (p = {}) => ['departments', 'list', p],
|
||||
heads: (p = {}) => ['departments', 'heads', p],
|
||||
locations: () => ['departments', 'locations'],
|
||||
names: (q = '') => ['departments', 'names', q],
|
||||
},
|
||||
interviews: {
|
||||
all: () => ['interviews'],
|
||||
range: (p = {}) => ['interviews', 'range', p],
|
||||
|
|
|
|||
|
|
@ -1,728 +0,0 @@
|
|||
/* ============================================================
|
||||
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
|
||||
|
||||
/** Extraction keeps the resume's own casing; SHOUTED headers read badly on cards. */
|
||||
export function displayName(name) {
|
||||
function displayName(name) {
|
||||
if (!name || /[a-z]/.test(name)) return name
|
||||
return name.toLowerCase().replace(/\p{L}+/gu, (w) => w[0].toUpperCase() + w.slice(1))
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ function ringColor(score) {
|
|||
}
|
||||
|
||||
/** The 120px .ats-ring shrunk to card size — same conic trick, no new CSS. */
|
||||
export function MiniRing({ score, size = 46 }) {
|
||||
function MiniRing({ score, size = 46 }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -1,534 +0,0 @@
|
|||
/* ============================================================
|
||||
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 DataTable from '../ui/DataTable'
|
||||
import Modal from '../ui/Modal'
|
||||
import SearchSelect from '../ui/SearchSelect'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
|
|
@ -25,10 +25,10 @@ import { friendlyAuthError } from '../lib/errors'
|
|||
import { platformLabel } from '../lib/platforms'
|
||||
import * as jobsApi from '../api/jobs'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as assignmentsApi from '../api/assignments'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
import * as usersApi from '../api/users'
|
||||
import * as requisitionsApi from '../api/requisitions'
|
||||
import * as departmentsApi from '../api/departments'
|
||||
import * as offersApi from '../api/offers'
|
||||
import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format'
|
||||
import { empTypes } from '../data/seed'
|
||||
|
|
@ -46,7 +46,7 @@ function deptValue(j) {
|
|||
return String(j.requisitionDepartment || j.department || '').trim()
|
||||
}
|
||||
|
||||
export function deptLabel(j) {
|
||||
function deptLabel(j) {
|
||||
return deptValue(j) || '—'
|
||||
}
|
||||
|
||||
|
|
@ -107,27 +107,31 @@ export default function Jobs() {
|
|||
const [status, setStatus] = useState('')
|
||||
const [type, setType] = useState('')
|
||||
|
||||
const [viewing, setViewing] = useState(null)
|
||||
const [viewingTab, setViewingTab] = useState('details')
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const canEdit = can('jobs.edit')
|
||||
const openJob = (id, tab) => navigate(`/job/${id}${tab === 'history' ? '?tab=history' : ''}`)
|
||||
const canDelete = can('jobs.delete')
|
||||
|
||||
// Deep-link intents from notifications, global search, the dashboard and the
|
||||
// manager portal. Consume once and replace history: jobs refetch after a
|
||||
// status PATCH used to replay openCreate and pop the create modal. A job
|
||||
// target (`/jobs?job=` / `?tab=history`, or state.openJob) now redirects to
|
||||
// the full job profile page at /job/:jobId.
|
||||
// status PATCH used to replay openCreate and pop the create modal over the
|
||||
// detail view. `/jobs?job=` / `?tab=history` is the notification target.
|
||||
useEffect(() => {
|
||||
const st = location.state
|
||||
const jobId = searchParams.get('job') || st?.openJob
|
||||
const tab = String(searchParams.get('tab') || '').toLowerCase()
|
||||
if (!st?.openCreate && !jobId) return
|
||||
if (jobId) {
|
||||
navigate(`/job/${jobId}${tab === 'history' ? '?tab=history' : ''}`, { replace: true })
|
||||
return
|
||||
}
|
||||
if (st?.openCreate) setCreating(true)
|
||||
if (jobId) {
|
||||
const job = jobs.find((j) => j.id === jobId)
|
||||
if (job) {
|
||||
setViewing(job)
|
||||
setViewingTab(tab === 'history' ? 'history' : 'details')
|
||||
} else if (!jobsQuery.isSuccess) return
|
||||
}
|
||||
const next = new URLSearchParams(searchParams)
|
||||
let queryChanged = false
|
||||
if (next.has('job')) {
|
||||
|
|
@ -139,8 +143,15 @@ export default function Jobs() {
|
|||
queryChanged = true
|
||||
}
|
||||
if (queryChanged) setSearchParams(next, { replace: true })
|
||||
if (st?.openCreate) navigate('.', { replace: true, state: null })
|
||||
}, [location.state, searchParams, navigate, setSearchParams])
|
||||
if (st?.openJob || st?.openCreate) navigate('.', { replace: true, state: null })
|
||||
}, [location.state, searchParams, jobs, jobsQuery.isSuccess, navigate, setSearchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewing) return
|
||||
const fresh = jobs.find((j) => j.id === viewing.id)
|
||||
if (fresh) setViewing(fresh)
|
||||
else if (jobsQuery.isSuccess) setViewing(null)
|
||||
}, [jobs]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const createJob = useMutation({
|
||||
mutationFn: async ({ payload, imageFile }) => {
|
||||
|
|
@ -199,6 +210,17 @@ export default function Jobs() {
|
|||
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
|
||||
})
|
||||
|
||||
const deleteJob = useMutation({
|
||||
mutationFn: (id) => jobsApi.remove(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||
setViewing(null)
|
||||
toast('Job deleted', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the job'), 'error'),
|
||||
})
|
||||
|
||||
const departmentOptions = useMemo(
|
||||
() => [...new Set(jobs.map(deptValue).filter(Boolean))].sort(),
|
||||
[jobs],
|
||||
|
|
@ -270,7 +292,7 @@ export default function Jobs() {
|
|||
key: '_a', label: 'Actions', align: 'right',
|
||||
render: (j) => (
|
||||
<div className="row-actions">
|
||||
<button className="act-btn" data-tip="View" aria-label="View job" onClick={(e) => { e.stopPropagation(); openJob(j.id) }}><Icon name="eye" /></button>
|
||||
<button className="act-btn" data-tip="View" aria-label="View job" onClick={(e) => { e.stopPropagation(); setViewing(j) }}><Icon name="eye" /></button>
|
||||
{canEdit && (
|
||||
<button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={(e) => { e.stopPropagation(); setEditing(j) }}><Icon name="edit" /></button>
|
||||
)}
|
||||
|
|
@ -359,15 +381,36 @@ export default function Jobs() {
|
|||
rows={rows}
|
||||
pageSize={50}
|
||||
empty="No requisitions match these filters."
|
||||
onRowClick={(j) => openJob(j.id)}
|
||||
onRowClick={(j) => setViewing(j)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewing && (
|
||||
<JobDetail
|
||||
key={viewing.id}
|
||||
job={viewing}
|
||||
initialTab={viewingTab}
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
statusBusy={setJobStatus.isPending}
|
||||
deleteBusy={deleteJob.isPending}
|
||||
onClose={() => { setViewing(null); setViewingTab('details') }}
|
||||
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
|
||||
onEdit={() => { setEditing(viewing); setViewing(null) }}
|
||||
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
|
||||
statusLabels={statusLabels}
|
||||
onDelete={() => {
|
||||
if (window.confirm(`Delete “${viewing.title}”?`)) deleteJob.mutate(viewing.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditJobForm
|
||||
job={editing}
|
||||
departmentOptions={departmentOptions}
|
||||
busy={updateJob.isPending}
|
||||
onClose={() => setEditing(null)}
|
||||
onSubmit={(body) => updateJob.mutate({ id: editing.id, body })}
|
||||
|
|
@ -376,6 +419,7 @@ export default function Jobs() {
|
|||
|
||||
{creating && (
|
||||
<JobForm
|
||||
departmentOptions={departmentOptions}
|
||||
busy={createJob.isPending}
|
||||
onClose={() => setCreating(false)}
|
||||
onSubmit={(payload, imageFile) => createJob.mutate({ payload, imageFile })}
|
||||
|
|
@ -385,7 +429,7 @@ export default function Jobs() {
|
|||
)
|
||||
}
|
||||
|
||||
export const SECTION_LABEL = {
|
||||
const SECTION_LABEL = {
|
||||
fontSize: 12, color: 'var(--text-3)', fontWeight: 600,
|
||||
textTransform: 'uppercase', marginBottom: 6,
|
||||
}
|
||||
|
|
@ -398,6 +442,91 @@ function fmtWhen(value) {
|
|||
return fmtDateTime(value) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable picker: type to filter, click a row to store the id.
|
||||
* Not free-text — the value is always an option id (or '' when allowEmpty).
|
||||
*/
|
||||
function SearchSelect({
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search…',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
allowEmpty = false,
|
||||
emptyLabel = 'Unassigned',
|
||||
error = false,
|
||||
onQueryChange,
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const root = useRef(null)
|
||||
const selected = options.find((o) => String(o.id) === String(value || ''))
|
||||
|
||||
useEffect(() => {
|
||||
function onDoc(e) {
|
||||
if (root.current && !root.current.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onQueryChange || !open) return
|
||||
onQueryChange(q)
|
||||
}, [q, open, onQueryChange])
|
||||
|
||||
const term = q.trim().toLowerCase()
|
||||
const filtered = onQueryChange
|
||||
? options
|
||||
: options.filter((o) => {
|
||||
if (!term) return true
|
||||
const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase()
|
||||
return hay.includes(term)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
|
||||
<input
|
||||
className={error ? 'err' : ''}
|
||||
value={open ? q : (selected?.name || '')}
|
||||
disabled={disabled || loading}
|
||||
placeholder={loading ? 'Loading…' : placeholder}
|
||||
autoComplete="off"
|
||||
onFocus={() => { setOpen(true); setQ('') }}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
|
||||
/>
|
||||
{open && !disabled && !loading && (
|
||||
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
|
||||
{allowEmpty && (
|
||||
<button
|
||||
type="button"
|
||||
className="dropdown-link"
|
||||
onClick={() => { onChange(''); setQ(''); setOpen(false) }}
|
||||
>
|
||||
{emptyLabel}
|
||||
</button>
|
||||
)}
|
||||
{filtered.length === 0 && (
|
||||
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>No matches</div>
|
||||
)}
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
type="button"
|
||||
key={o.id}
|
||||
className="dropdown-link"
|
||||
onClick={() => { onChange(String(o.id)); setQ(''); setOpen(false) }}
|
||||
>
|
||||
{o.name}
|
||||
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sameIdList(a, b) {
|
||||
const x = [...(a || [])].map(String)
|
||||
const y = [...(b || [])].map(String)
|
||||
|
|
@ -527,49 +656,6 @@ function useRecruiterDirectory() {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Department search dropdown over GET /department/names?search=. Options are
|
||||
* `{id, name}`; the picked row is kept in the list so a selection made from an
|
||||
* earlier search (or the job's saved department) still renders its name.
|
||||
*/
|
||||
function useDepartmentPicker(initialPicked = null) {
|
||||
const [deptQ, setDeptQ] = useState('')
|
||||
const [debouncedDeptQ, setDebouncedDeptQ] = useState('')
|
||||
const [pickedDept, setPickedDept] = useState(initialPicked)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedDeptQ(deptQ.trim()), 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [deptQ])
|
||||
|
||||
const departmentsQuery = useQuery({
|
||||
queryKey: qk.departments.names(debouncedDeptQ),
|
||||
queryFn: async () => departmentsApi.toRows(await departmentsApi.listNames({ search: debouncedDeptQ })),
|
||||
placeholderData: keepPreviousData,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const departmentOptions = useMemo(() => {
|
||||
const rows = departmentsQuery.data ?? []
|
||||
if (pickedDept && !rows.some((o) => String(o.id) === String(pickedDept.id))) {
|
||||
return [pickedDept, ...rows]
|
||||
}
|
||||
return rows
|
||||
}, [departmentsQuery.data, pickedDept])
|
||||
|
||||
/** Pick by name — the requisition picker only knows its department as text. */
|
||||
async function pickByName(name) {
|
||||
const term = String(name || '').trim().toLowerCase()
|
||||
if (!term) return null
|
||||
const rows = departmentsApi.toRows(await departmentsApi.listNames({ search: name.trim() }))
|
||||
const match = rows.find((d) => String(d.name).trim().toLowerCase() === term)
|
||||
if (match) setPickedDept(match)
|
||||
return match || null
|
||||
}
|
||||
|
||||
return { departmentOptions, departmentsQuery, setDeptQ, pickedDept, setPickedDept, pickByName }
|
||||
}
|
||||
|
||||
function useRequisitionPicker(initialPicked = null, jobPostId = null) {
|
||||
const [reqQ, setReqQ] = useState('')
|
||||
const [debouncedReqQ, setDebouncedReqQ] = useState('')
|
||||
|
|
@ -607,43 +693,17 @@ function useRequisitionPicker(initialPicked = null, jobPostId = null) {
|
|||
return { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq }
|
||||
}
|
||||
|
||||
/** 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 }) {
|
||||
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||
const managersQuery = useManagerDirectory()
|
||||
const recruitersQuery = useRecruiterDirectory()
|
||||
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker()
|
||||
const dept = useDepartmentPicker()
|
||||
|
||||
const form = useFormState({
|
||||
hiring_manager_id: '',
|
||||
current_recruiter_ids: [],
|
||||
requisition_id: '',
|
||||
title: '',
|
||||
department_id: '',
|
||||
department: '',
|
||||
location: '',
|
||||
employment_type: empTypes[0] || 'Full-time',
|
||||
vacancies: '1',
|
||||
|
|
@ -718,7 +778,7 @@ function JobForm({ busy, onClose, onSubmit }) {
|
|||
// The cover image is uploaded separately right after the row exists.
|
||||
onSubmit({
|
||||
title: v.title.trim(),
|
||||
department_id: v.department_id || null,
|
||||
department: v.department.trim() || null,
|
||||
location: v.location.trim() || null,
|
||||
employment_type: v.employment_type || null,
|
||||
vacancies,
|
||||
|
|
@ -742,7 +802,7 @@ function JobForm({ busy, onClose, onSubmit }) {
|
|||
// field itself and empty values before building the prompt.
|
||||
const assistContext = () => ({
|
||||
title: form.values.title,
|
||||
department: dept.pickedDept?.name || '',
|
||||
department: form.values.department,
|
||||
location: form.values.location,
|
||||
employment_type: form.values.employment_type,
|
||||
experience_min: form.values.experience_min,
|
||||
|
|
@ -796,9 +856,7 @@ function JobForm({ busy, onClose, onSubmit }) {
|
|||
if (opt) {
|
||||
setPickedReq(opt)
|
||||
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
||||
if (!form.values.department_id && opt.department) {
|
||||
dept.pickByName(opt.department).then((d) => { if (d) form.setField('department_id', d.id) })
|
||||
}
|
||||
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
|
||||
}
|
||||
}}
|
||||
onQueryChange={setReqQ}
|
||||
|
|
@ -823,7 +881,7 @@ function JobForm({ busy, onClose, onSubmit }) {
|
|||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Hiring manager</label>
|
||||
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
||||
<SearchSelect
|
||||
options={managersQuery.data ?? []}
|
||||
value={form.values.hiring_manager_id}
|
||||
|
|
@ -856,8 +914,16 @@ function JobForm({ busy, onClose, onSubmit }) {
|
|||
<div className="form-field">
|
||||
<div className="field-label-row">
|
||||
<label>Department</label>
|
||||
{assist('department')}
|
||||
</div>
|
||||
<DepartmentSearchSelect dept={dept} value={form.values.department_id} onChange={(id) => form.setField('department_id', id)} disabled={busy} />
|
||||
<input
|
||||
{...field('department')}
|
||||
list="job-department-options"
|
||||
placeholder="e.g. Engineering"
|
||||
/>
|
||||
<datalist id="job-department-options">
|
||||
{departmentOptions.map((d) => <option key={d} value={d} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<div className="field-label-row">
|
||||
|
|
@ -993,7 +1059,7 @@ function JobForm({ busy, onClose, onSubmit }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||
const managersQuery = useManagerDirectory()
|
||||
const recruitersQuery = useRecruiterDirectory()
|
||||
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
|
||||
|
|
@ -1007,11 +1073,10 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
|
|||
: null,
|
||||
j.id,
|
||||
)
|
||||
const dept = useDepartmentPicker(j.departmentId ? { id: j.departmentId, name: j.department || 'Current department' } : null)
|
||||
const form = useFormState({
|
||||
requisition_id: j.requisitionId || '',
|
||||
title: j.title || '',
|
||||
department_id: j.departmentId || '',
|
||||
department: j.department || '',
|
||||
location: j.location || '',
|
||||
employment_type: j.type || '',
|
||||
vacancies: j.vacancies != null ? String(j.vacancies) : '1',
|
||||
|
|
@ -1024,7 +1089,7 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
|
|||
|
||||
const assistContext = () => ({
|
||||
title: form.values.title,
|
||||
department: dept.pickedDept?.name || '',
|
||||
department: form.values.department,
|
||||
location: form.values.location,
|
||||
employment_type: form.values.employment_type,
|
||||
experience_min: form.values.experience_min,
|
||||
|
|
@ -1053,7 +1118,7 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
|
|||
if (Object.keys(errors).length) return
|
||||
onSubmit({
|
||||
title,
|
||||
department_id: form.values.department_id || null,
|
||||
department: form.values.department.trim() || null,
|
||||
location: form.values.location.trim() || null,
|
||||
employment_type: form.values.employment_type || null,
|
||||
vacancies: Number(form.values.vacancies) || 1,
|
||||
|
|
@ -1098,9 +1163,7 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
|
|||
if (opt) {
|
||||
setPickedReq(opt)
|
||||
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
||||
if (!form.values.department_id && opt.department) {
|
||||
dept.pickByName(opt.department).then((d) => { if (d) form.setField('department_id', d.id) })
|
||||
}
|
||||
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
|
||||
}
|
||||
}}
|
||||
onQueryChange={setReqQ}
|
||||
|
|
@ -1123,7 +1186,7 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
|
|||
<FieldError>{form.errors.title}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Hiring manager</label>
|
||||
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
||||
<SearchSelect
|
||||
options={managersQuery.data ?? []}
|
||||
value={form.values.hiring_manager_id}
|
||||
|
|
@ -1149,8 +1212,10 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
|
|||
<div className="form-field">
|
||||
<div className="field-label-row">
|
||||
<label>Department</label>
|
||||
{assist('department')}
|
||||
</div>
|
||||
<DepartmentSearchSelect dept={dept} value={form.values.department_id} onChange={(id) => form.setField('department_id', id)} disabled={busy} />
|
||||
<input list="edit-job-depts" value={form.values.department} onChange={(e) => form.setField('department', e.target.value)} disabled={busy} />
|
||||
<datalist id="edit-job-depts">{departmentOptions.map((d) => <option key={d} value={d} />)}</datalist>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<div className="field-label-row">
|
||||
|
|
@ -1195,7 +1260,7 @@ export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit
|
|||
* Hiring-manager + recruiter pointers on one requisition.
|
||||
* Writes go through PATCH /jobs/update; job_assignments keeps the interval log.
|
||||
*/
|
||||
export function JobOwnership({ job, canEdit }) {
|
||||
function JobOwnership({ job, canEdit }) {
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const managersQuery = useManagerDirectory()
|
||||
|
|
@ -1219,7 +1284,7 @@ export function JobOwnership({ job, canEdit }) {
|
|||
<div style={SECTION_LABEL}>Ownership</div>
|
||||
<div className="form-grid" style={{ marginBottom: 12 }}>
|
||||
<div className="form-field">
|
||||
<label>Hiring manager</label>
|
||||
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
||||
{canEdit ? (
|
||||
<SearchSelect
|
||||
options={managersQuery.data ?? []}
|
||||
|
|
@ -1268,7 +1333,7 @@ export function JobOwnership({ job, canEdit }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function JobHistory({ historyQuery, statusQuery, offersQuery }) {
|
||||
function JobHistory({ historyQuery, statusQuery, offersQuery }) {
|
||||
const assignments = historyQuery.data ?? []
|
||||
const statusRows = statusQuery.data ?? []
|
||||
const offerRows = offersQuery?.data ?? []
|
||||
|
|
@ -1394,7 +1459,7 @@ function AssignmentHistoryRow({ row }) {
|
|||
/* Cover image, when the post has one — fetched with the bearer token into an
|
||||
object URL, because a bare <img src> cannot carry auth headers. null (404)
|
||||
simply renders nothing. */
|
||||
export function JobCover({ jobId }) {
|
||||
function JobCover({ jobId }) {
|
||||
const [url, setUrl] = useState(null)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
|
|
@ -1411,3 +1476,136 @@ export function JobCover({ jobId }) {
|
|||
if (!url) return null
|
||||
return <img src={url} alt="Job cover" className="job-cover" />
|
||||
}
|
||||
|
||||
function JobDetail({
|
||||
job: j, initialTab = 'details', canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
|
||||
statusLabels = jobsApi.JOB_STATUSES,
|
||||
}) {
|
||||
const [tab, setTab] = useState(initialTab === 'history' ? 'history' : 'details')
|
||||
const historyQuery = useQuery({
|
||||
queryKey: qk.assignments.job(j.id),
|
||||
queryFn: async () => {
|
||||
const res = await assignmentsApi.listJob(j.id, { currentOnly: false })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map((r) => assignmentsApi.toAssignmentView(r))
|
||||
},
|
||||
enabled: Boolean(j.id),
|
||||
retry: false,
|
||||
})
|
||||
const statusQuery = useQuery({
|
||||
queryKey: qk.jobs.statusHistory(j.id),
|
||||
queryFn: async () => {
|
||||
const res = await jobsApi.listStatusHistory(j.id)
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
enabled: Boolean(j.id),
|
||||
retry: false,
|
||||
})
|
||||
const offersQuery = useQuery({
|
||||
queryKey: qk.offers.list({ jobPostId: j.id, top: 200 }),
|
||||
queryFn: async () => {
|
||||
const res = await offersApi.list({ jobPostId: j.id, top: 200 })
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
enabled: Boolean(j.id),
|
||||
retry: false,
|
||||
})
|
||||
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0) + (offersQuery.data?.length ?? 0)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Job Details"
|
||||
subtitle={deptValue(j) || undefined}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
{canDelete && (
|
||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)', marginRight: 'auto' }} onClick={onDelete} disabled={deleteBusy}>
|
||||
<Icon name="trash" /> {deleteBusy ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
||||
{canEdit && (
|
||||
<button className="btn btn-secondary" onClick={onEdit}><Icon name="edit" /> Edit</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={onPublish}><Icon name="send" /> Publish</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<JobCover jobId={j.id} />
|
||||
|
||||
<div className="flex items-center gap-16 mb-18">
|
||||
<span className="kpi-icn i-indigo" style={{ width: 52, height: 52, borderRadius: 14 }}>
|
||||
<Icon name="briefcase" />
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 19, fontWeight: 700 }}>{j.title}</div>
|
||||
<div className="text-muted">{[deptValue(j), j.location].filter(Boolean).join(' · ') || '—'}</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
{canEdit ? (
|
||||
<select
|
||||
className="select"
|
||||
value={j.status}
|
||||
disabled={statusBusy}
|
||||
onChange={(e) => onStatus(e.target.value)}
|
||||
>
|
||||
{statusLabels.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<Badge>{j.status}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
tabs={[
|
||||
{ key: 'details', label: 'Details' },
|
||||
{ key: 'history', label: 'History', count: historyCount || undefined },
|
||||
]}
|
||||
/>
|
||||
|
||||
{tab === 'details' && (
|
||||
<>
|
||||
<div className="info-grid mb-18">
|
||||
<div className="info-item"><div className="il">Department</div><div className="iv">{deptLabel(j)}</div></div>
|
||||
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Assigned Recruiters</div><div className="iv">{j.recruiter || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
|
||||
</div>
|
||||
|
||||
<JobOwnership job={j} canEdit={canEdit} />
|
||||
|
||||
{j.description && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<div className="mb-16">
|
||||
<div style={SECTION_LABEL}>Description</div>
|
||||
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!!(j.skills && j.skills.length) && (
|
||||
<div>
|
||||
<div style={SECTION_LABEL}>Required Skills</div>
|
||||
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} offersQuery={offersQuery} />}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,35 @@
|
|||
/* ============================================================
|
||||
Recruiter Hub — live, by pointing every analytics endpoint at one recruiter.
|
||||
|
||||
The trick that makes this screen real: /analytics/kpis, /hiring-trend and
|
||||
/funnel all take a `recruiter_id`, so selecting a recruiter re-scopes the
|
||||
whole page server-side rather than filtering a client-side array. The
|
||||
recruiter list itself is /analytics/recruiter-performance, which is also the
|
||||
leaderboard.
|
||||
|
||||
TEN OF THE PROTOTYPE'S EIGHTEEN TILES ARE GONE. workload %, efficiency %, SLA
|
||||
state, interview completion %, avg response time, TAT %, star rating, jobs
|
||||
awaiting approval and jobs overdue have no column, no table and in most cases
|
||||
no concept behind them — there is no approval workflow and no requisition
|
||||
deadline in the schema. They were random numbers re-rolled on every render.
|
||||
What replaced them is derived from real counts and labelled as such:
|
||||
conversion rate is hires ÷ candidates, offer acceptance is accepted ÷ sent.
|
||||
|
||||
The workload heatmap survived because interviews are real: it buckets
|
||||
/interview/fetch over the last five weeks by weekday. It is TEAM-WIDE, not
|
||||
per recruiter — the interviews table has no recruiter column — and the card
|
||||
says so rather than implying the selected person owns all of it.
|
||||
|
||||
Tasks belong here too: GET /tasks/fetch?assignee_id= the selected recruiter
|
||||
is the worklist the prototype filed under Recruiter Hub. Completing a row
|
||||
writes the same /tasks/update the Tasks screen uses, so the two stay in
|
||||
sync. Hidden without tasks.view; the rest of the hub still loads.
|
||||
|
||||
Interviews Today / upcoming / the heatmap join interviews → job_posts via
|
||||
COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id) and
|
||||
filter on current_recruiter_id. The leaderboard ranks by completed
|
||||
requisitions (requisition_status=completed), not inbox hires.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||
|
||||
import DataTable from '../ui/DataTable'
|
||||
import Modal from '../ui/Modal'
|
||||
import DepartmentSelect from '../ui/DepartmentSelect'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
|
|
@ -270,8 +269,7 @@ export default function Requisitions() {
|
|||
|
||||
function blankForm() {
|
||||
return {
|
||||
department_id: '',
|
||||
department_name: '',
|
||||
department: '',
|
||||
title: '',
|
||||
date: toDateInput(new Date().toISOString()),
|
||||
date_needed: '',
|
||||
|
|
@ -310,8 +308,7 @@ function fromRow(row) {
|
|||
const rep = row.replacement_for || {}
|
||||
const ref = row.refferal_by || {}
|
||||
return {
|
||||
department_id: pos.department_id ? String(pos.department_id) : '',
|
||||
department_name: pos.department || '',
|
||||
department: pos.department || '',
|
||||
title: pos.title || '',
|
||||
date: toDateInput(pos.date),
|
||||
date_needed: toDateInput(pos.date_needed),
|
||||
|
|
@ -348,8 +345,7 @@ function fromRow(row) {
|
|||
function toPayload(f) {
|
||||
const body = {
|
||||
position: {
|
||||
department_id: emptyToNull(f.department_id),
|
||||
department: emptyToNull(f.department_name),
|
||||
department: emptyToNull(f.department),
|
||||
title: emptyToNull(f.title),
|
||||
date: emptyToNull(f.date),
|
||||
date_needed: emptyToNull(f.date_needed),
|
||||
|
|
@ -457,14 +453,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>From (Dept.)</label>
|
||||
<DepartmentSelect
|
||||
value={fields.department_id}
|
||||
onChange={(id, name) => {
|
||||
set('department_id', id)
|
||||
set('department_name', name)
|
||||
}}
|
||||
fallbackName={fields.department_name}
|
||||
/>
|
||||
<input value={fields.department} onChange={(e) => set('department', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Job title <span className="req">*</span></label>
|
||||
|
|
@ -518,7 +507,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="hf-check">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.jd_available}
|
||||
|
|
@ -653,7 +642,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
</div>
|
||||
<div className="hf-sign">
|
||||
<span className="hf-sign-role">Approved by · Director HR</span>
|
||||
<label className="hf-note hf-check" style={{ margin: 0 }}>
|
||||
<label className="hf-note" style={{ margin: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.approved_by_hr}
|
||||
|
|
@ -669,7 +658,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
</div>
|
||||
<div className="hf-sign">
|
||||
<span className="hf-sign-role">Approved by · VP</span>
|
||||
<label className="hf-note hf-check" style={{ margin: 0 }}>
|
||||
<label className="hf-note" style={{ margin: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.approved_by_vp}
|
||||
|
|
@ -685,7 +674,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
</div>
|
||||
<div className="hf-sign">
|
||||
<span className="hf-sign-role">Approved by · SVP</span>
|
||||
<label className="hf-note hf-check" style={{ margin: 0 }}>
|
||||
<label className="hf-note" style={{ margin: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
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; }
|
||||
|
||||
/* AI field assist (ui/AiFieldAssist.jsx) */
|
||||
.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 22px; }
|
||||
.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.ai-assist { position: relative; display: inline-flex; }
|
||||
.ai-assist-btn { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 6px; color: var(--primary); background: transparent; transition: .15s; }
|
||||
.ai-assist-btn svg { width: 14px; height: 14px; }
|
||||
|
|
@ -1422,30 +1422,6 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.cand-meta svg { width: 13px; height: 13px; }
|
||||
.cand-company { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-3); }
|
||||
|
||||
/* Job profile page (JobProfile.jsx) — hero stats, ranked suggested-candidate
|
||||
cards and the optional-skill highlight shared by cards and the Details tab. */
|
||||
.cand-page-crumb a { color: var(--text-3); }
|
||||
.cand-page-crumb a:hover { color: var(--text); }
|
||||
.job-hero { flex-wrap: wrap; margin-bottom: 18px; }
|
||||
.job-hero-icn { width: 60px; height: 60px; border-radius: 14px; flex: none; display: grid; place-items: center; background: var(--primary-soft); color: var(--primary); }
|
||||
.job-hero-icn svg { width: 26px; height: 26px; }
|
||||
.job-hero-id { min-width: 0; }
|
||||
.job-hero .ph-name { overflow-wrap: anywhere; }
|
||||
.job-hero-actions { margin-left: auto; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.hero-stat { text-align: center; min-width: 60px; }
|
||||
.hero-stat .v { font-family: var(--font-display); font-size: 22px; font-weight: 600; line-height: 1.1; }
|
||||
.hero-stat .l { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||||
.cand-sub { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 16px 0 12px; font-size: 13px; color: var(--text-2); }
|
||||
.auto-tag { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; padding: 2px 7px; border-radius: 6px; background: var(--primary-soft); color: var(--primary); }
|
||||
.select.active-filter { border-color: var(--primary); color: var(--primary); font-weight: 600; }
|
||||
.cand-card .card-body { position: relative; }
|
||||
.cand-rank { position: absolute; top: 12px; left: 12px; width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-sunken); color: var(--text-2); font-size: 11px; font-weight: 700; }
|
||||
.cand-card.ranked .cand-head { padding-left: 22px; }
|
||||
.cand-chip.ok { background: var(--success-soft); color: var(--success); }
|
||||
.cand-chip.opt { background: var(--purple-soft); color: var(--purple); }
|
||||
.tag.tag-optional { display: inline-flex; align-items: center; gap: 4px; background: var(--purple-soft); color: var(--purple); }
|
||||
.tag.tag-optional svg { width: 11px; height: 11px; }
|
||||
|
||||
/* Find Talent toolbar (Talent.jsx): the job picker takes the slack, the
|
||||
location controls hold a readable fixed width. The widths live here, not
|
||||
inline, so the ≤640 block can stack everything full-width. */
|
||||
|
|
@ -1961,10 +1937,6 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.hf-block-title { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
|
||||
.hf-block-title label { display: flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 600; color: var(--text-2); }
|
||||
.hf-note { font-size: 12.5px; color: var(--text-3); margin: 2px 0 10px; }
|
||||
/* Checkbox + text on one line, box flush left. Overrides `.form-field input`
|
||||
(width:100% + padding), which otherwise stretches the checkbox and centres it. */
|
||||
.hf-check { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; cursor: pointer; }
|
||||
.hf-check input[type="checkbox"] { width: auto; padding: 0; margin: 0; flex: none; }
|
||||
|
||||
/* Rating table: the paper grid — scale header, radio-dot cells, average foot */
|
||||
.hf-rate { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
|
|
@ -2306,7 +2278,6 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
@media (max-width: 640px) {
|
||||
.g-kpi-7 { grid-template-columns: 1fr; }
|
||||
.cand-page-actions { width: 100%; }
|
||||
.job-hero-actions { width: 100%; margin-left: 0; }
|
||||
.cand-page-actions .btn { flex: 1 1 auto; justify-content: center; }
|
||||
.hf-sign-grid { grid-template-columns: 1fr; }
|
||||
.hf-summary { grid-template-columns: repeat(2, 1fr); }
|
||||
|
|
@ -2318,35 +2289,3 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
@media (max-width: 400px) {
|
||||
.hf-summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ================= DEPARTMENTS ================= */
|
||||
.dept-card { --tone: var(--primary); --tone-soft: var(--primary-soft); display: flex; flex-direction: column; }
|
||||
.dept-card.tone-success { --tone: var(--success); --tone-soft: var(--success-soft); }
|
||||
.dept-card.tone-warning { --tone: var(--warning); --tone-soft: var(--warning-soft); }
|
||||
.dept-card.tone-purple { --tone: var(--purple); --tone-soft: var(--purple-soft); }
|
||||
.dept-card.tone-info { --tone: var(--info); --tone-soft: var(--info-soft); }
|
||||
.dept-card > .card-body { display: flex; flex-direction: column; flex: 1; }
|
||||
.dept-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
|
||||
.dept-card-id { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.dept-icn { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; flex: none; background: var(--tone-soft); color: var(--tone); }
|
||||
.dept-icn svg { width: 20px; height: 20px; }
|
||||
.dept-name { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; font-size: var(--fs-md); font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
|
||||
.code-chip { font-size: 11px; font-weight: 700; letter-spacing: .4px; padding: 2px 8px; border-radius: 20px; background: var(--tone-soft); color: var(--tone); }
|
||||
.dept-desc { font-size: var(--fs-sm); color: var(--text-2); line-height: 1.5; margin: 0 0 16px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.dept-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: auto; }
|
||||
.dept-stat { text-align: center; min-width: 0; }
|
||||
.dept-stat-v { font-size: 18px; font-weight: 600; line-height: 1.3; }
|
||||
.dept-stat-v.is-on, .dept-stat-v.is-off { font-size: var(--fs-sm); line-height: 23px; }
|
||||
.dept-stat-v.is-on { color: var(--success); }
|
||||
.dept-stat-v.is-off { color: var(--text-3); }
|
||||
.dept-stat-l { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||||
.dept-card .divider { margin: 14px 0; }
|
||||
.dept-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.dept-head { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: var(--fs-sm); color: var(--text-2); }
|
||||
.dept-head > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dept-avatar { width: 26px; height: 26px; font-size: 10px; }
|
||||
.dept-status-toggle { display: flex; align-items: center; gap: 10px; padding: 7px 12px; border: 1px solid var(--border-strong); border-radius: 9px; font-size: var(--fs-sm); min-height: 40px; }
|
||||
.dept-location-list { max-height: 320px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-elev); box-shadow: var(--shadow-sm); padding: 4px; }
|
||||
.dept-location-option { display: block; width: 100%; text-align: left; padding: 8px 10px; border-radius: 7px; font-size: var(--fs-sm); color: var(--text); }
|
||||
.dept-location-option:hover, .dept-location-option:focus-visible { background: var(--bg-sunken); outline: none; }
|
||||
.dept-location-note { padding: 8px 10px; font-size: var(--fs-sm); color: var(--text-3); }
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
/* ============================================================
|
||||
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>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
/* ============================================================
|
||||
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
|
||||
// (e.g. 8001 locally because another service holds 8000).
|
||||
proxy: {
|
||||
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox|s3|forms|requisitions|department)(/|$)': {
|
||||
'^/(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)(/|$)': {
|
||||
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
// Several API prefixes double as SPA routes (/jobs, /inbox, …).
|
||||
|
|
|
|||
Loading…
Reference in New Issue