diff --git a/.gitignore b/.gitignore
index bffe937..b41fdd0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,10 @@ dist/**/*
.claude/
.audit.js
+# Local macOS launcher (not shared — machine-specific)
+Start.command
+start.command
+
# Backups
.backup-prebrand/
*.bak
@@ -88,4 +92,7 @@ frontend/dist/index.html
# nothing under either path that should be ignored.
frontend/dist/**
nginx.conf
-smoke.test.mjs
\ No newline at end of file
+smoke.test.mjs
+**.docx
+**.docs
+Annex**
diff --git a/Annexure A - Employee Requisition Form 2.doc b/Annexure A - Employee Requisition Form 2.doc
new file mode 100644
index 0000000..1639ffb
Binary files /dev/null and b/Annexure A - Employee Requisition Form 2.doc differ
diff --git a/Annexure E - Interview Evaluation Form 1.docx b/Annexure E - Interview Evaluation Form 1.docx
new file mode 100644
index 0000000..cd87f40
Binary files /dev/null and b/Annexure E - Interview Evaluation Form 1.docx differ
diff --git a/Main.dc.html b/Main.dc.html
new file mode 100644
index 0000000..7fd3571
--- /dev/null
+++ b/Main.dc.html
@@ -0,0 +1,654 @@
+
+
+
Senior backend engineer with 6 years building high-throughput payment and fulfillment services. Led the migration of a monolith to event-driven microservices on Kafka, cutting checkout latency by 40%. Comfortable owning a service from design through on-call.
+
+
+
+
Resume
+
+ PDF
+
Ada_Lovelace_Resume.pdfPDF · Mar 12, 2026
+
+
+
+
+
+
+
+
+
Ratings
+
+
+
+
+
+
+ {{ratingText}}
+
+
+
+
+
Recruiter
+
+ MK
+
Meera KhanHiring team
+
+
+
+
+
AI Screening
+
+
+ 82
+ Strong Match
+
+
Meets every mandatory requirement with demonstrated production experience; missing only the Kubernetes depth the role prefers.
+
+
+
+
+
Suggested Roles
+
Platform EngineerStaff Backend Engineer
+
+
+
+
+
+
+
+
+
+
+
+ PDF
+
Ada_Lovelace_Resume.pdfOriginal CV from the application
+
+
+
+
+
+
+
+
+
+
Technical — System Design
Mar 15, 2026 · 3:00 PM
+ Scheduled
+
+
Scheduling a new round attaches it to this application.
+
+
+
+
+
+
+
+
+
Technical scorecard
Submitted by Farhan Ali · Mar 15, 2026
+ Submitted
+
+
+
+
Offer approval
Pending hiring manager sign-off
+ Pending
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{n.initials}}
+
{{n.author}}
{{n.text}}
{{n.when}}
+
+
+
+
+
+
+
+
+
Profile viewed by Meera Khan
1h ago
+
Email sent: Interview invitation
1 day ago
+
Assessment score updated to 82%
2 days ago
+
+
+
+
+
+
+
Application received
Applied via Careers page
+
AI screening completed
Match score: 82%
+
Interview scheduled
Technical — System Design
+
+
+
+
+
+
+
Today
+
Stage changed
Screening → Interview
Meera Khan · 10:14 AM
+
Feedback submitted
by Farhan Ali
+
+
+
+
+
+
+
+
+
+
diff --git a/RBAC_CONTEXT_PROMPT.md b/RBAC_CONTEXT_PROMPT.md
new file mode 100644
index 0000000..e77ec2a
--- /dev/null
+++ b/RBAC_CONTEXT_PROMPT.md
@@ -0,0 +1,835 @@
+# RBAC Context Prompt — HR-ATS-Portal (TalentFlow)
+
+> Paste this whole file as context for an engineer or LLM that must reproduce this system's
+> role-based access control exactly. Everything below is taken from the code on branch
+> `Department_Module`. File references point back to the source of truth.
+> Where the code has a quirk or gap, it is written down as-is under **Known behaviour to
+> reproduce (or consciously fix)**. Do not tidy these away silently.
+
+---
+
+## 0. Your task
+
+You are implementing an access-control layer that must behave **identically** to the one
+described here. That means the same:
+
+- data model (tags → bundles → roles → users),
+- permission vocabulary (136 `module.action` tags),
+- resolution algorithm (live, per request, deny-by-default),
+- enforcement order and HTTP status codes and error strings,
+- rules against privilege escalation,
+- row-level data scoping (who sees which jobs, candidates, offers, requisitions),
+- role-name-based business rules (tasks, assignments, hiring-manager portal),
+- frontend gating (routes, sidebar, buttons) and the Access Control matrix editor.
+
+When this document and your instincts disagree, follow this document.
+
+---
+
+## 1. Core concepts in one paragraph
+
+A **permission tag** is one atomic `module.action` string, such as `candidates.view`. A
+**permission bundle** (table `permissions`) is a named JSONB array of tag ids. A **role** is a
+named JSONB array of bundle ids. A **user** has zero or one role (`users.role_id`, nullable).
+On every authenticated request the server walks role → bundles → tags and builds a flat list of
+tag names. Route guards check that list. Service code then narrows which rows are visible
+using a few helper predicates, some of which look at tags and some at the role name. Tags
+never go into the JWT, so a permission change takes effect on the server on the very next
+request.
+
+---
+
+## 2. Data model
+
+PostgreSQL, schema `app`. SQLModel/SQLAlchemy async. Every table soft-deletes
+(`is_deleted`) and has an `is_active` flag.
+
+### 2.1 `permission_tags` — [backend/role/models.py](backend/role/models.py)
+
+| column | type | notes |
+|---|---|---|
+| `id` | int PK | seed order matters: it sets matrix ordering and resolution ordering |
+| `tag_name` | varchar(64) unique, indexed | `"{module}.{action}"` |
+| `module` | varchar(32) indexed | |
+| `action` | varchar(32) | |
+| `description` | text null | |
+| `is_active` / `is_deleted` | bool | |
+| `created_at` / `updated_at` | timestamptz | |
+
+Unique constraint `uq_permission_tags_module_action` on (`module`, `action`).
+
+### 2.2 `permissions` (bundles)
+
+| column | type | notes |
+|---|---|---|
+| `id` | int PK | |
+| `name` | varchar(64) unique | |
+| `description` | text null | |
+| `permission_tags` | JSONB int[] | ids from `permission_tags.id`, **no FK** |
+| `is_system` | bool | system bundles cannot be renamed |
+| `is_active` / `is_deleted` / timestamps | | |
+
+### 2.3 `roles`
+
+| column | type | notes |
+|---|---|---|
+| `id` | int PK | |
+| `role_name` | varchar(64) unique | free text (initial Alembic revision used an enum; the model is a varchar) |
+| `description` | text null | |
+| `permissions` | JSONB int[] | ids from `permissions.id`, **no FK** |
+| `is_system` | bool | system roles cannot be renamed or deleted |
+| `is_active` / `is_deleted` / timestamps | | |
+
+### 2.4 `users` — [backend/users/models.py](backend/users/models.py)
+
+Only the RBAC-relevant columns:
+
+| column | type | notes |
+|---|---|---|
+| `id` | uuid PK | the JWT `sub` |
+| `email` | unique | |
+| `role_id` | int FK → `roles.id`, **nullable** | `role` relationship is `lazy="selectin"` |
+| `is_active` | bool, default **false** | set true by email confirmation |
+| `is_approved` | bool, default **false** | set true by an admin (or at admin creation) |
+| `is_deleted` | bool | |
+
+### 2.5 System role keys — `EnumRoles`
+
+```
+system_administrator hr_administrator recruiter hiring_manager
+department_head interviewer ceo candidate
+```
+
+Seed ids follow that order: 1 system_administrator, 2 hr_administrator, 3 recruiter,
+4 hiring_manager, … , 8 candidate. **Some code hardcodes ids 4 and 8** (see §12).
+
+Migration `026` soft-deletes `hr_administrator`, `interviewer` and `ceo` when no live user
+holds them. The organisation runs four staff roles (`system_administrator`, `recruiter`,
+`hiring_manager`, `department_head`) plus `candidate`. Migration `027` moves members of a
+hand-made role named `Manager` onto `hiring_manager` and soft-deletes it. Code still accepts the
+names `manager` and `admin` (see §6).
+
+---
+
+## 3. Permission vocabulary — 17 modules × 8 actions = 136 tags
+
+Source: `PermissionModule`, `PermissionAction`, `PermissionTag` in
+[backend/users/permissions.py](backend/users/permissions.py).
+
+**Modules:** `dashboard, inbox, jobs, candidates, pipeline, department, interviews,
+assessments, offers, reports, analytics, job_board, settings, rbac_users, tasks, talent,
+requisitions`
+
+**Actions:** `view, create, edit, delete, approve, export, manage, configure`
+
+Rules:
+
+1. `PermissionTag` is a `str` Enum listing every combination explicitly. At import time,
+ `_assert_vocabulary_complete()` raises `RuntimeError("PermissionTag vocabulary drift:
+ missing=[...] extra=[...]")` unless the enum equals the full modules × actions cross-product.
+ The server will not boot with a partial vocabulary.
+2. Always serialise with `.value`. `f"{PermissionTag.X}"` renders the enum repr.
+3. DB rows are seeded idempotently (`ON CONFLICT (tag_name) DO NOTHING`) by manual SQL
+ migrations: `001` (first 13 modules = 104 tags), `004` tasks, `007` talent,
+ `019` requisitions, `038` department. Comments in the code that say "104" or "120" tags are
+ stale. The real total is 136.
+4. Two actions carry special meaning beyond "may use the screen":
+ - `*.manage` on `requisitions`, `candidates` and `offers` **removes row scoping**. See §6.
+ - `requisitions.configure` is an **opt-in to scoping**, not a screen permission. See §6.
+
+---
+
+## 4. Resolution algorithm — `Roles.resolve_tags(session, role)`
+
+```
+if role is None or not role.is_active or role.is_deleted: return ()
+perm_ids = role.permissions
+if not perm_ids or not a list: return ()
+bundles = SELECT permissions WHERE id IN perm_ids AND is_active AND NOT is_deleted
+tag_ids = concat(bundle.permission_tags for each bundle whose permission_tags is a non-empty list)
+if not tag_ids: return ()
+tags = SELECT permission_tags WHERE id IN tag_ids AND is_active AND NOT is_deleted
+sort tags by (id, tag_name); de-duplicate by tag_name keeping first
+return tuple(tag_name ...)
+```
+
+Properties you must preserve:
+
+- **Deny by default, never error.** Dangling ids, inactive or deleted bundles, and inactive or
+ deleted tags simply add nothing.
+- **Union.** Tags from every attached bundle are merged. There are no negative grants.
+- **Live.** It runs on every request inside `get_current_user`. Nothing is cached server-side
+ and nothing is put in the token.
+- **Stable order.** The output is ordered by tag id, which is seed order.
+
+---
+
+## 5. Authentication and enforcement pipeline
+
+### 5.1 Tokens — [backend/users/plugins.py](backend/users/plugins.py)
+
+PyJWT HS256. Every token carries `sub`, `type`, `iat`, `exp`, `jti`.
+`decode_token(token, expected_type=...)` rejects a token whose `type` does not match.
+
+| type | default lifetime | extra claims |
+|---|---|---|
+| `access` | 30 min | `email`, `role_id` |
+| `refresh` | 7 days | — |
+| `reset` | 10 min | `crid` |
+
+The token's `role_id` is informational only. Authorization always reloads the user from the DB.
+There is no logout endpoint, no denylist and no `jti` tracking.
+
+Login, signup, refresh and `/users/create` return:
+`{access_token, refresh_token, token_type:"bearer", expires_in, data: , 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
+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
+```
+
+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
+ `_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._)`.
+5. Add the route to `frontend/src/app/routes.js` with `permission: '.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 →
+`` ("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 `` 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 |
diff --git a/backend/candidate_forms/app.py b/backend/candidate_forms/app.py
index e596d25..a7d61f0 100644
--- a/backend/candidate_forms/app.py
+++ b/backend/candidate_forms/app.py
@@ -99,6 +99,22 @@ async def search_requisitions(
raise HTTPException(status_code=500, detail=str(e))
+@router.get("/forms/requisition/open-count")
+async def count_open_requisitions(
+ current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
+ session:AsyncSession=Depends(get_session),
+):
+ """Open requisitions: unlinked, or linked to a job post that is still open."""
+ try:
+ service=RequisitionForm(session=session)
+ data=await service.count_open(current_user)
+ return JSONResponse(content={"data":{"open":data},"status_code":200})
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.get("/forms/requisition/fetch")
async def fetch_requisition_form(
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
diff --git a/backend/candidate_forms/enums.py b/backend/candidate_forms/enums.py
index 8cede5b..51b0afa 100644
--- a/backend/candidate_forms/enums.py
+++ b/backend/candidate_forms/enums.py
@@ -16,10 +16,14 @@ class Position(BaseModel):
date_needed:Optional[date]
type:Optional[EmploymentType]
job_description:Optional[str]
+ period_from:Optional[date]=None
+ period_to:Optional[date]=None
+ jd_available:Optional[bool]=None
class InternalRecommendate(BaseModel):
employee_name:Optional[str]=None
employee_department:Optional[str]=None
+ entity:Optional[str]=None
class ReplacementFor(BaseModel):
to_replace:Optional[str]
diff --git a/backend/candidate_forms/models.py b/backend/candidate_forms/models.py
index 5deb238..15aa3f6 100644
--- a/backend/candidate_forms/models.py
+++ b/backend/candidate_forms/models.py
@@ -2,12 +2,11 @@ import uuid
from datetime import datetime, date as Date, timezone
from typing import TYPE_CHECKING, Optional
-from sqlalchemy import DateTime, Enum as SAEnum, JSON, func, or_
+from sqlalchemy import DateTime, Enum as SAEnum, JSON, and_, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select
from candidate_forms.enums import EmploymentType
-
if TYPE_CHECKING:
from job.job_post.models import JobPosts
@@ -34,9 +33,13 @@ class Requisition(SQLModel, table=True):
),
)
job_description: Optional[str] = None
+ period_from: Optional[Date] = None
+ period_to: Optional[Date] = None
+ jd_available: Optional[bool] = None
employee_name: Optional[str] = None
employee_department: Optional[str] = None
+ entity: Optional[str] = None
to_replace: Optional[str] = None
grade: Optional[str] = None
@@ -127,6 +130,32 @@ class Requisition(SQLModel, table=True):
result = await session.execute(statement)
return list(result.scalars().all())
+ @classmethod
+ async def count_open(cls, session: AsyncSession, created_by=None) -> int:
+ """Open requisitions: not linked to a live job post, or linked to one whose
+ requisition_status is still open. A closed / on-hold / completed job closes
+ its requisition. job_posts.requisition_id is 1:1, so the join never fans out.
+ """
+ from job.job_post.enums import RequisitionStatus
+ from job.job_post.models import JobPosts
+
+ statement = (
+ select(func.count())
+ .select_from(cls)
+ .outerjoin(
+ JobPosts,
+ and_(JobPosts.requisition_id == cls.id, JobPosts.is_deleted == False), # noqa: E712
+ )
+ .where(
+ cls.is_deleted == False, # noqa: E712
+ or_(JobPosts.id.is_(None), JobPosts.requisition_status == RequisitionStatus.OPEN.value),
+ )
+ )
+ if created_by is not None:
+ statement = statement.where(cls.created_by == created_by)
+ result = await session.execute(statement)
+ return int(result.scalar_one())
+
@classmethod
async def insert_form(cls, session: AsyncSession, fields: dict):
position = fields.get("position") if fields.get("position") else {}
@@ -139,8 +168,12 @@ class Requisition(SQLModel, table=True):
date_needed=position.get("date_needed") if position.get("date_needed") else None,
employment_type=EmploymentType(position.get("type")) if position.get("type") else None,
job_description=position.get("job_description") if position.get("job_description") else None,
+ period_from=position.get("period_from") if position.get("period_from") else None,
+ period_to=position.get("period_to") if position.get("period_to") else None,
+ jd_available=position.get("jd_available") if position.get("jd_available") is not None else None,
employee_name=referral.get("employee_name") if referral.get("employee_name") else None,
employee_department=referral.get("employee_department") if referral.get("employee_department") else None,
+ entity=referral.get("entity") if referral.get("entity") else None,
to_replace=replacement.get("to_replace") if replacement.get("to_replace") else None,
grade=replacement.get("grade") if replacement.get("grade") else None,
recruitment_title=replacement.get("title") if replacement.get("title") else None,
@@ -183,6 +216,12 @@ class Requisition(SQLModel, table=True):
row.employment_type = EmploymentType(position.get("type")) if position.get("type") else None
if "job_description" in position:
row.job_description = position.get("job_description") if position.get("job_description") else None
+ if "period_from" in position:
+ row.period_from = position.get("period_from") if position.get("period_from") else None
+ if "period_to" in position:
+ row.period_to = position.get("period_to") if position.get("period_to") else None
+ if "jd_available" in position:
+ row.jd_available = position.get("jd_available") if position.get("jd_available") is not None else None
if "replacement_for" in fields:
replacement = fields.get("replacement_for") if fields.get("replacement_for") else {}
if "to_replace" in replacement:
@@ -205,6 +244,8 @@ class Requisition(SQLModel, table=True):
row.employee_name = referral.get("employee_name") if referral.get("employee_name") else None
if "employee_department" in referral:
row.employee_department = referral.get("employee_department") if referral.get("employee_department") else None
+ if "entity" in referral:
+ row.entity = referral.get("entity") if referral.get("entity") else None
if "initiated_by" in fields:
row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None
if "initiated_date" in fields:
diff --git a/backend/candidate_forms/plugins.py b/backend/candidate_forms/plugins.py
index 3d0addc..a59fd29 100644
--- a/backend/candidate_forms/plugins.py
+++ b/backend/candidate_forms/plugins.py
@@ -80,7 +80,8 @@ FORM_DEFINITIONS = {
"criteria": [
{"key": "core_job_knowledge", "label": "Core Job Knowledge & Domain Expertise"},
{"key": "relevant_experience", "label": "Depth of Relevant Experience"},
- {"key": "problem_solving", "label": "Problem Solving & Analytical Reasoning"},
+ {"key": "problem_solving", "label": "Problem Solving"},
+ {"key": "analytical_reasoning", "label": "Analytical Reasoning"},
{"key": "tools_proficiency", "label": "Technical Tools & Systems Proficiency"},
{"key": "quality_of_work", "label": "Quality of Work & Attention to Detail"},
],
@@ -109,27 +110,41 @@ FORM_DEFINITIONS = {
),
"has_recommendation": True,
},
+ # form_type/section/field keys below stay "cultural_fit"/"cultural"/"cultural_note" —
+ # renamed labels only. Titles and criterion labels are denormalized into every
+ # saved row at write time (see module docstring), so historical rows keep the
+ # "Cultural Fit" wording they were saved under while new rows pick up the fuller
+ # revision 2 "HR Evaluation" section below; the key stays stable so old rows keep
+ # validating and combined_summary()'s "cultural" lookup keeps matching both.
"cultural_fit": {
- "title": "Cultural Fit",
+ "title": "HR Evaluation",
"source": "Annexure E - Interview Evaluation Form",
"scale_note": RATING_SCALE_NOTE,
"sections": [
{
"key": "cultural",
- "title": "CULTURAL FIT",
- "average_label": "CULTURAL FIT SECTION",
+ "title": "HR EVALUATION",
+ "average_label": "HR EVALUATION SECTION",
"criteria": [
- {"key": "company_values", "label": "Alignment with Company Values"},
+ {"key": "basic_jd_requirement", "label": "Basic JD requirement"},
+ {"key": "company_values", "label": "Alignment with Company Culture"},
{"key": "professionalism", "label": "Professionalism & Integrity"},
{"key": "collaboration", "label": "Collaboration & Team Orientation"},
- {"key": "adaptability", "label": "Adaptability to Change"},
- {"key": "work_ethic", "label": "Work Ethic & Reliability"},
+ {"key": "adaptability", "label": "Adaptability"},
+ {"key": "agility", "label": "Agility"},
+ {"key": "work_ethic", "label": "Work Ethics"},
+ {"key": "communication_articulation", "label": "Communication & Articulation"},
+ {"key": "problem_solving_orientation", "label": "Problem Solving & Solution Orientation"},
+ {"key": "critical_thinking", "label": "Critical Thinking & Analytical Capability"},
+ {"key": "initiative", "label": "Initiative & Proactiveness"},
+ {"key": "decision_making", "label": "Decision Making"},
+ {"key": "leadership", "label": "Leadership"},
],
},
],
"fields": (
_EVALUATION_HEADER_FIELDS
- + [{"key": "cultural_note", "label": "Cultural Fit — Notes", "kind": "text"}]
+ + [{"key": "cultural_note", "label": "HR Evaluation — Notes", "kind": "text"}]
+ _EVALUATION_FOOTER_FIELDS
),
"has_recommendation": True,
@@ -174,6 +189,7 @@ FORM_DEFINITIONS = {
{"key": "internal_recommendation", "label": "INCASE OF INTERNAL RECOMMENDATE", "kind": "bool"},
{"key": "recommended_employee_name", "label": "EMPLOYEE NAME", "kind": "text"},
{"key": "recommended_employee_department", "label": "EMPLOYEE DEPARTMENT", "kind": "text"},
+ {"key": "entity", "label": "Entity", "kind": "text"},
{"key": "initiated_by", "label": "Initiated By — Name", "kind": "text"},
{"key": "initiated_date", "label": "Initiated By — Date", "kind": "date"},
{"key": "recommended_by", "label": "Recommended By — Name (Director)", "kind": "text"},
@@ -354,7 +370,10 @@ def combined_summary(rows):
`rows` are candidate_forms records (attribute access: form_type, created_at,
sections). The latest interview_analysis row supplies the technical and
- behavioral averages, the latest cultural_fit row the cultural average.
+ behavioral averages, the latest cultural_fit row the "cultural" section
+ average — cultural_fit's own section carries the fuller HR Evaluation
+ criteria as of revision 2, but the key stays "cultural" so this lookup
+ (and the `cultural_avg` key below) don't need to change with it.
The combined overall (mean of the three section averages, 2 dp, already
ranged onto 0–100) appears only once all three exist. Returns None when
neither evaluation exists. Legacy 1–4 section averages are converted
diff --git a/backend/candidate_forms/serializers.py b/backend/candidate_forms/serializers.py
index b621ed3..b2968de 100644
--- a/backend/candidate_forms/serializers.py
+++ b/backend/candidate_forms/serializers.py
@@ -85,6 +85,9 @@ def serialize_requisition(row) -> dict:
"date_needed": _date(row.date_needed),
"type": _enum(row.employment_type),
"job_description": row.job_description,
+ "period_from": _date(row.period_from),
+ "period_to": _date(row.period_to),
+ "jd_available": row.jd_available,
},
"replacement_for": {
"to_replace": row.to_replace,
@@ -98,6 +101,7 @@ def serialize_requisition(row) -> dict:
"refferal_by": {
"employee_name": row.employee_name,
"employee_department": row.employee_department,
+ "entity": row.entity,
},
"initiated_by": row.initiated_by,
"initiated_date": _date(row.initiated_date),
diff --git a/backend/candidate_forms/views.py b/backend/candidate_forms/views.py
index 64681aa..379a508 100644
--- a/backend/candidate_forms/views.py
+++ b/backend/candidate_forms/views.py
@@ -435,6 +435,11 @@ 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, q, top=50, job_post_id=None):
rows = await Requisition.search(
self.session, q, top=top, job_post_id=job_post_id,
diff --git a/backend/department/app.py b/backend/department/app.py
new file mode 100644
index 0000000..c987b10
--- /dev/null
+++ b/backend/department/app.py
@@ -0,0 +1,162 @@
+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,
+ 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))
\ No newline at end of file
diff --git a/backend/department/models.py b/backend/department/models.py
new file mode 100644
index 0000000..c241dff
--- /dev/null
+++ b/backend/department/models.py
@@ -0,0 +1,174 @@
+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
+from users.models import Users
+
+if TYPE_CHECKING:
+ 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"})
+
+ 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()}
diff --git a/backend/department/plugins.py b/backend/department/plugins.py
new file mode 100644
index 0000000..a053659
--- /dev/null
+++ b/backend/department/plugins.py
@@ -0,0 +1,50 @@
+import uuid
+from datetime import datetime, timezone
+
+
+def now_utc() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def as_uuid(record_id) -> uuid.UUID | None:
+ """Parse a UUID from any id-ish value; None when blank or malformed."""
+ if record_id in (None, ""):
+ return None
+ try:
+ return uuid.UUID(str(record_id))
+ except ValueError:
+ return None
+
+
+def location_options(search=None) -> list[str]:
+ """`{City} - {Country}` for every city in global_cities.Countries, optionally filtered."""
+ from global_cities import Countries
+
+ term = (search or "").strip().lower()
+ seen = set()
+ options = []
+ for country, cities in Countries.items():
+ for city in cities:
+ label = f"{city} - {country}"
+ if label in seen or (term and term not in label.lower()):
+ continue
+ seen.add(label)
+ options.append(label)
+ return options
+
+
+def department_job_metrics(job_rows, applicants_by_job) -> dict:
+ """Roll job-level rows up to {department_id: {job_posts, open_roles, candidates}}.
+
+ `job_rows` are (department_id, job_post_id, requisition_status); `applicants_by_job`
+ maps str(job_post_id) -> unique applicants on that job. Open roles are job posts
+ whose requisition_status is "open", the same meaning analytics uses.
+ """
+ metrics = {}
+ for department_id, job_post_id, requisition_status in job_rows or []:
+ m = metrics.setdefault(department_id, {"job_posts": 0, "open_roles": 0, "candidates": 0})
+ m["job_posts"] += 1
+ if requisition_status == "open":
+ m["open_roles"] += 1
+ m["candidates"] += int(applicants_by_job.get(str(job_post_id), 0))
+ return metrics
diff --git a/backend/department/serializers.py b/backend/department/serializers.py
new file mode 100644
index 0000000..4cfa0b8
--- /dev/null
+++ b/backend/department/serializers.py
@@ -0,0 +1,38 @@
+from department.models import Department
+
+
+def serialize_head_option(user) -> dict:
+ return {"id": str(user.id), "name": user.name, "email": user.email}
+
+
+def serialize_department(
+ department: Department,
+ *,
+ head_name: str | None = None,
+ parent_name: str | None = None,
+ metrics: dict | None = None,
+) -> dict:
+ return {
+ "id": str(department.id),
+ "name": department.name,
+ "short_code": department.short_code,
+ "subtitle": department.subtitle,
+ "description": department.description,
+ "is_active": department.is_active,
+ "parent_department_id": (
+ str(department.parent_department_id) if department.parent_department_id else None
+ ),
+ "parent_department_name": parent_name,
+ "department_head_id": (
+ str(department.department_head_id) if department.department_head_id else None
+ ),
+ "department_head_name": head_name,
+ "location": list(department.location or []),
+ "job_posts": (metrics or {}).get("job_posts", 0),
+ "open_roles": (metrics or {}).get("open_roles", 0),
+ "candidates": (metrics or {}).get("candidates", 0),
+ "created_by": str(department.created_by) if department.created_by else None,
+ "updated_by": str(department.updated_by) if department.updated_by else None,
+ "created_at": department.created_at.isoformat() if department.created_at else None,
+ "updated_at": department.updated_at.isoformat() if department.updated_at else None,
+ }
diff --git a/backend/department/views.py b/backend/department/views.py
new file mode 100644
index 0000000..f8f845b
--- /dev/null
+++ b/backend/department/views.py
@@ -0,0 +1,77 @@
+from fastapi import HTTPException
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from department.models import Department
+from department.plugins import as_uuid,department_job_metrics,location_options
+from department.serializers import serialize_department,serialize_head_option
+from job.job_post.models import JobPosts
+from users.models import Users
+
+def serialize_department_name(row):
+ return {"id":str(row.id),"name":row.name}
+
+class DepartmentService:
+ def __init__(self,session:AsyncSession):
+ self.session=session
+
+ async def get_department_names(self,search):
+ rows=await Department.get_department_names(self.session,search)
+ return [serialize_department_name(row) for row in rows]
+
+ async def _serialize_many(self,departments):
+ head_names=await Users.names_by_ids(self.session,[d.department_head_id for d in departments])
+ parent_names=await Department.names_by_ids(self.session,[d.parent_department_id for d in departments])
+ job_rows=await Department.job_posts_for(self.session,[d.id for d in departments])
+ job_ids=list({str(r[1]) for r in job_rows})
+ applicants={}
+ if job_ids:
+ stats,_=await JobPosts.fetch_job_stats(self.session,ids=job_ids)
+ applicants={str(row["job_post_id"]):row["total_applicants"] for row in stats}
+ metrics=department_job_metrics(job_rows,applicants)
+ return [
+ serialize_department(
+ d,
+ head_name=head_names.get(str(d.department_head_id)),
+ parent_name=parent_names.get(d.parent_department_id),
+ metrics=metrics.get(d.id),
+ )
+ for d in departments
+ ]
+
+ async def get_department(self,record_id):
+ department=await Department.get_by_id(self.session,record_id)
+ if not department:
+ raise HTTPException(status_code=404,detail="Department not found")
+ return (await self._serialize_many([department]))[0]
+
+ async def get_departments(self,top,skip,search,is_active):
+ departments=await Department.get_departments(self.session,top,skip,search,is_active)
+ total=await Department.count_departments(self.session,search,is_active)
+ return await self._serialize_many(departments),total
+
+ async def create_department(self,payload,user_id):
+ actor=as_uuid(user_id)
+ fields={**payload,"created_by":actor,"updated_by":actor}
+ try:
+ department=await Department.insert_department(self.session,fields)
+ except IntegrityError as e:
+ raise HTTPException(status_code=409,detail=str(e.orig)) from e
+ return (await self._serialize_many([department]))[0]
+
+ async def update_department(self,record_id,payload,user_id):
+ fields={**payload,"updated_by":as_uuid(user_id)}
+ try:
+ department=await Department.update_department(self.session,record_id,fields)
+ except IntegrityError as e:
+ raise HTTPException(status_code=409,detail=str(e.orig)) from e
+ if not department:
+ raise HTTPException(status_code=404,detail="Department not found")
+ return (await self._serialize_many([department]))[0]
+
+ async def get_head_options(self,role_id,top,skip,search):
+ rows=await Users.get_users(self.session,top,skip,search,role_id)
+ return [serialize_head_option(row) for row in rows]
+
+ async def get_location_options(self,search):
+ return location_options(search)
diff --git a/backend/job/app.py b/backend/job/app.py
index 0f4eec9..64c4042 100644
--- a/backend/job/app.py
+++ b/backend/job/app.py
@@ -143,6 +143,7 @@ class HiringCostCreate(BaseModel):
class JobUpdate(BaseModel):
title: str | None = None
department: str | None = None
+ department_id: UUID | None = None
location: str | None = None
employment_type: str | None = None
vacancies: int | None = None
diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py
index 112365f..0800ac4 100644
--- a/backend/job/job_post/models.py
+++ b/backend/job/job_post/models.py
@@ -12,6 +12,7 @@ from job.job_post.enums import RequisitionStatus
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
from candidate_forms.models import Requisition
+ from department.models import Department
from users.models import Users
@@ -25,6 +26,11 @@ class JobPosts(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
title: str = Field(index=True)
+ # Many job posts -> one department. `department` (below) stays the free-text name that
+ # analytics / filters / talent pool key off; set both together (see JobPost views).
+ # The relationship is `department_ref` because `department` is already that column.
+ department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id", index=True)
+ department_ref: Optional["Department"] = Relationship(back_populates="job_posts", sa_relationship_kwargs={"lazy": "selectin"})
user: Optional["Users"] = Relationship(
back_populates="job_posts",
diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py
index 1adb195..f0e1785 100644
--- a/backend/job/job_post/serializers.py
+++ b/backend/job/job_post/serializers.py
@@ -41,6 +41,7 @@ def serialize_job_post(row, *, names=None) -> dict:
"title": row.title,
# Talent Pool / candidate filters key off attached job_posts.department.
"department": row.department or None,
+ "department_id": str(row.department_id) if row.department_id else None,
"employment_type": row.employment_type,
"location": row.location,
"experience_min": row.experience_min,
@@ -82,7 +83,8 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
return {
"id": str(row.id),
"title": row.title,
- "department": row.department or None,
+ "department": row.department_ref.name if row.department_ref else None,
+ "department_id": str(row.department_id) if row.department_id else None,
"location": row.location,
"employment_type": row.employment_type,
"vacancies": row.vacancies,
diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py
index 3f4f408..1ecc62f 100644
--- a/backend/job/job_post/views.py
+++ b/backend/job/job_post/views.py
@@ -83,6 +83,7 @@ class JobPostCreate(BaseModel):
location: str | None = None
employment_type: str | None = None
department: str | None = None
+ department_id: UUID | None = None
vacancies: int = 1
description: str | None = None
platform: str | None = None
@@ -181,6 +182,7 @@ class JobPost:
"salary":payload.get("salary") or "Anonymous",
# department is NOT NULL with a server_default of "" — pass "", never None.
"department":payload.get("department") or "",
+ "department_id":None,
"vacancies":payload.get("vacancies") or 1,
"description":payload.get("description"),
"post_text":text,
@@ -191,6 +193,10 @@ class JobPost:
# Only set platform when it is actually known: passing None would override the
# column default and break the NOT NULL constraint. Buffer's channelService
# replaces this with the authoritative value once the post is created.
+ if payload.get("department_id"):
+ department=await self._require_department(payload.get("department_id"))
+ fields["department_id"]=department.id
+ fields["department"]=department.name
known_platform=service or normalize_platform(payload.get("platform"),aliases)
if known_platform:
fields["platform"]=known_platform
@@ -424,6 +430,13 @@ class JobPost:
hiring_manager_name=names.get(str(row.hiring_manager_id)),
)
+ 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:
raise HTTPException(status_code=401,detail="Not authenticated")
@@ -444,6 +457,16 @@ class JobPost:
fields["salary"]=str(high)
if "department" in fields and fields["department"] is None:
fields["department"]=""
+ if "department_id" in payload:
+ if payload.get("department_id"):
+ department=await self._require_department(payload.get("department_id"))
+ fields["department_id"]=department.id
+ fields["department_ref"]=department
+ fields["department"]=department.name
+ else:
+ fields["department_id"]=None
+ fields["department_ref"]=None
+ fields["department"]=""
assignment=Assignment(self.session)
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
diff --git a/backend/main.py b/backend/main.py
index 56f4348..2e33469 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -24,6 +24,7 @@ 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
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
logger=logging.getLogger("main")
@@ -136,3 +137,4 @@ 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)
diff --git a/backend/migrations/manual/037_requisition_period_jd_entity.sql b/backend/migrations/manual/037_requisition_period_jd_entity.sql
new file mode 100644
index 0000000..327e473
--- /dev/null
+++ b/backend/migrations/manual/037_requisition_period_jd_entity.sql
@@ -0,0 +1,15 @@
+-- 037_requisition_period_jd_entity.sql
+-- Annexure A revision 2 added three fields the original 020 table never had:
+-- "If not permanent, specify the period From/To" on the position block, the
+-- mandatory "JD Available Yes/No" flag (distinct from the free-text
+-- job_description), and "Entity" alongside Employee Name/Department in the
+-- internal-recommendation block. Matches Requisition in
+-- backend/candidate_forms/models.py. Applied at startup by
+-- alembic_setup.run_manual_sql() — needed because prod boots with
+-- DB_AUTOGENERATE=false and never autogenerates new columns.
+
+ALTER TABLE app.requisitions
+ ADD COLUMN IF NOT EXISTS period_from date,
+ ADD COLUMN IF NOT EXISTS period_to date,
+ ADD COLUMN IF NOT EXISTS jd_available boolean,
+ ADD COLUMN IF NOT EXISTS entity varchar;
diff --git a/backend/migrations/manual/038_departments.sql b/backend/migrations/manual/038_departments.sql
new file mode 100644
index 0000000..92ddca4
--- /dev/null
+++ b/backend/migrations/manual/038_departments.sql
@@ -0,0 +1,92 @@
+-- 038_departments.sql
+-- Departments as a managed entity (backend/department/models.py): name, short
+-- code, subtitle (replaces the design's "Cost Center"), description, status,
+-- head, parent department and region/location list. Plus the `department`
+-- permission module (8 tags), a `department_management` bundle holding them,
+-- and that bundle attached to the admin roles.
+--
+-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql()
+-- and recorded in manual_migrations. Needed because prod boots with
+-- DB_AUTOGENERATE=false and never autogenerates new tables. Index names match
+-- the db_setup NAMING_CONVENTION so a dev DB that autogenerated first is a no-op.
+-- Users must log in again afterwards — the frontend caches /users/me permissions.
+
+-- =============================================================================
+-- 1. Table
+-- =============================================================================
+CREATE TABLE IF NOT EXISTS app.departments (
+ id uuid PRIMARY KEY,
+ name varchar NOT NULL,
+ short_code varchar(10) NOT NULL,
+ subtitle varchar,
+ description varchar,
+ is_active boolean NOT NULL DEFAULT true,
+ parent_department_id uuid REFERENCES app.departments(id),
+ department_head_id uuid REFERENCES app.users(id),
+ location jsonb NOT NULL DEFAULT '[]'::jsonb,
+ created_at timestamptz NOT NULL DEFAULT NOW(),
+ updated_at timestamptz NOT NULL DEFAULT NOW(),
+ created_by uuid REFERENCES app.users(id),
+ updated_by uuid REFERENCES app.users(id)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS ix_departments_name
+ ON app.departments (name);
+
+CREATE UNIQUE INDEX IF NOT EXISTS ix_departments_short_code
+ ON app.departments (short_code);
+
+CREATE INDEX IF NOT EXISTS ix_departments_parent_department_id
+ ON app.departments (parent_department_id);
+
+-- =============================================================================
+-- 2. The 8 department.* permission tags
+-- =============================================================================
+INSERT INTO app.permission_tags
+ (tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
+VALUES
+ ('department.view', 'department', 'view', NULL, NOW(), NOW(), true, false),
+ ('department.create', 'department', 'create', NULL, NOW(), NOW(), true, false),
+ ('department.edit', 'department', 'edit', NULL, NOW(), NOW(), true, false),
+ ('department.delete', 'department', 'delete', NULL, NOW(), NOW(), true, false),
+ ('department.approve', 'department', 'approve', NULL, NOW(), NOW(), true, false),
+ ('department.export', 'department', 'export', NULL, NOW(), NOW(), true, false),
+ ('department.manage', 'department', 'manage', NULL, NOW(), NOW(), true, false),
+ ('department.configure', 'department', 'configure', NULL, NOW(), NOW(), true, false)
+ON CONFLICT (tag_name) DO NOTHING;
+
+-- =============================================================================
+-- 3. Bundle holding all eight department tags
+-- =============================================================================
+INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
+SELECT
+ 'department_management',
+ 'Departments: view, create, edit and manage departments',
+ (
+ SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
+ FROM app.permission_tags
+ WHERE is_deleted = false
+ AND module = 'department'
+ ),
+ true,
+ NOW(),
+ NOW(),
+ true,
+ false
+WHERE NOT EXISTS (
+ SELECT 1 FROM app.permissions WHERE name = 'department_management'
+);
+
+-- =============================================================================
+-- 4. Attach the bundle to the admin roles (idempotent)
+-- =============================================================================
+UPDATE app.roles r
+SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
+ updated_at = NOW()
+FROM app.permissions p
+WHERE p.name = 'department_management'
+ AND r.role_name IN (
+ 'system_administrator',
+ 'hr_administrator'
+ )
+ AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
diff --git a/backend/migrations/manual/039_requisition_department_id.sql b/backend/migrations/manual/039_requisition_department_id.sql
new file mode 100644
index 0000000..2fb9459
--- /dev/null
+++ b/backend/migrations/manual/039_requisition_department_id.sql
@@ -0,0 +1,50 @@
+-- 039_requisition_department_id.sql
+-- Many requisitions -> one department. Adds requisitions.department_id, the FK
+-- behind Requisition.department_id / Requisition.department and
+-- Department.requisitions (backend/candidate_forms/models.py,
+-- backend/department/models.py).
+--
+-- A new file, not an edit to 020 or 038: manual migrations run once and are
+-- recorded in manual_migrations, so changes to an applied file never reach an
+-- existing database. Applied at startup by alembic_setup.run_manual_sql()
+-- after 038, so app.departments already exists.
+--
+-- The legacy free-text requisitions.department column is kept and only read
+-- here to backfill: rows whose text equals a department's name or short code
+-- (case-insensitive, trimmed) get that department's id. Unmatched rows stay
+-- NULL. Drop the text column in a later migration once nothing reads it.
+
+ALTER TABLE app.requisitions
+ ADD COLUMN IF NOT EXISTS department_id uuid;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'fk_requisitions_department_id_departments'
+ ) THEN
+ ALTER TABLE app.requisitions
+ ADD CONSTRAINT fk_requisitions_department_id_departments
+ FOREIGN KEY (department_id) REFERENCES app.departments (id);
+ END IF;
+END $$;
+
+CREATE INDEX IF NOT EXISTS ix_requisitions_department_id
+ ON app.requisitions (department_id);
+
+-- Backfill from the legacy text column, if it is still there.
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'app'
+ AND table_name = 'requisitions'
+ AND column_name = 'department'
+ ) THEN
+ UPDATE app.requisitions r
+ SET department_id = d.id
+ FROM app.departments d
+ WHERE r.department_id IS NULL
+ AND lower(btrim(r.department)) IN (lower(d.name), lower(d.short_code));
+ END IF;
+END $$;
diff --git a/backend/migrations/manual/040_job_post_department_id.sql b/backend/migrations/manual/040_job_post_department_id.sql
new file mode 100644
index 0000000..14c9314
--- /dev/null
+++ b/backend/migrations/manual/040_job_post_department_id.sql
@@ -0,0 +1,63 @@
+-- 040_job_post_department_id.sql
+-- Move the department link from requisitions to job posts: many job posts -> one
+-- department. Reverses 039 (requisitions.department_id) and adds
+-- job_posts.department_id, the FK behind JobPosts.department_id /
+-- JobPosts.department_ref and Department.job_posts.
+--
+-- job_posts.department (free text) stays: analytics, filters and the talent pool
+-- key off it, and the app now writes the department's name there whenever
+-- department_id is set.
+--
+-- Idempotent; applied at startup by alembic_setup.run_manual_sql() after 039.
+
+-- =============================================================================
+-- 1. requisitions: drop the 039 link, keeping the department name as text
+-- =============================================================================
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'app' AND table_name = 'requisitions' AND column_name = 'department_id'
+ ) THEN
+ -- Rows created while the link existed wrote only department_id; carry the name back.
+ UPDATE app.requisitions r
+ SET department = d.name
+ FROM app.departments d
+ WHERE r.department_id = d.id
+ AND (r.department IS NULL OR btrim(r.department) = '');
+
+ ALTER TABLE app.requisitions
+ DROP CONSTRAINT IF EXISTS fk_requisitions_department_id_departments;
+ DROP INDEX IF EXISTS app.ix_requisitions_department_id;
+ ALTER TABLE app.requisitions DROP COLUMN department_id;
+ END IF;
+END $$;
+
+-- =============================================================================
+-- 2. job_posts.department_id -> departments.id
+-- =============================================================================
+ALTER TABLE app.job_posts
+ ADD COLUMN IF NOT EXISTS department_id uuid;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'fk_job_posts_department_id_departments'
+ ) THEN
+ ALTER TABLE app.job_posts
+ ADD CONSTRAINT fk_job_posts_department_id_departments
+ FOREIGN KEY (department_id) REFERENCES app.departments (id);
+ END IF;
+END $$;
+
+CREATE INDEX IF NOT EXISTS ix_job_posts_department_id
+ ON app.job_posts (department_id);
+
+-- Backfill: job posts whose text department equals a department's name or short
+-- code (case-insensitive, trimmed). Unmatched rows stay NULL.
+UPDATE app.job_posts j
+SET department_id = d.id
+FROM app.departments d
+WHERE j.department_id IS NULL
+ AND lower(btrim(j.department)) IN (lower(d.name), lower(d.short_code));
diff --git a/backend/tests/test_candidate_forms.py b/backend/tests/test_candidate_forms.py
index 49e1a7a..3cfa57a 100644
--- a/backend/tests/test_candidate_forms.py
+++ b/backend/tests/test_candidate_forms.py
@@ -52,7 +52,7 @@ class TestNormalizeSections:
normalized, overall = normalize_sections("interview_analysis", [])
assert [s["key"] for s in normalized] == ["technical", "behavioral"]
technical = normalized[0]
- assert len(technical["criteria"]) == 5
+ assert len(technical["criteria"]) == 6
assert technical["criteria"][0]["label"] == "Core Job Knowledge & Domain Expertise"
assert technical["average"] is None
assert overall is None
@@ -149,8 +149,8 @@ class TestDefinitions:
def test_paper_parity_criterion_counts(self):
ia = FORM_DEFINITIONS["interview_analysis"]
cf = FORM_DEFINITIONS["cultural_fit"]
- assert [len(s["criteria"]) for s in ia["sections"]] == [5, 5]
- assert [len(s["criteria"]) for s in cf["sections"]] == [5]
+ assert [len(s["criteria"]) for s in ia["sections"]] == [6, 5]
+ assert [len(s["criteria"]) for s in cf["sections"]] == [13]
def test_stage_gate_vocabulary(self):
assert set(FORM_READY_STATUSES) == {"INTERVIEW", "OFFER", "HIRED", "APPROVED"}
diff --git a/backend/users/models.py b/backend/users/models.py
index a5b1f90..2dc47ce 100644
--- a/backend/users/models.py
+++ b/backend/users/models.py
@@ -31,11 +31,6 @@ class Users(SQLModel, table=True):
role: Roles | None = Relationship(back_populates="users",
sa_relationship_kwargs={"lazy": "selectin"}
)
- # selectin, not joined: this is a one-to-many, so a joined load would repeat the
- # user row once per post. Without an explicit strategy the default is a lazy load,
- # which raises MissingGreenlet the moment anything touches it under asyncio.
- # foreign_keys must match the other side: job_posts also has current_recruiter_id
- # and hiring_manager_id into this table, so this relation has to say created_by.
job_posts: List[JobPosts] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
diff --git a/backend/users/permissions.py b/backend/users/permissions.py
index 728bc19..8263b56 100644
--- a/backend/users/permissions.py
+++ b/backend/users/permissions.py
@@ -30,6 +30,7 @@ class PermissionModule(str, Enum):
JOBS = "jobs"
CANDIDATES = "candidates"
PIPELINE = "pipeline"
+ DEPARTMENT = "department"
INTERVIEWS = "interviews"
ASSESSMENTS = "assessments"
OFFERS = "offers"
@@ -71,6 +72,16 @@ class PermissionTag(str, Enum):
DASHBOARD_EXPORT = "dashboard.export"
DASHBOARD_MANAGE = "dashboard.manage"
DASHBOARD_CONFIGURE = "dashboard.configure"
+
+ DEPARTMENT_VIEW = "department.view"
+ DEPARTMENT_CREATE = "department.create"
+ DEPARTMENT_EDIT = "department.edit"
+ DEPARTMENT_DELETE = "department.delete"
+ DEPARTMENT_APPROVE = "department.approve"
+ DEPARTMENT_EXPORT = "department.export"
+ DEPARTMENT_MANAGE = "department.manage"
+ DEPARTMENT_CONFIGURE = "department.configure"
+
INBOX_VIEW = "inbox.view"
INBOX_CREATE = "inbox.create"
INBOX_EDIT = "inbox.edit"
diff --git a/frontend/candidate-browse.test.mjs b/frontend/candidate-browse.test.mjs
new file mode 100644
index 0000000..0bb0e8c
--- /dev/null
+++ b/frontend/candidate-browse.test.mjs
@@ -0,0 +1,42 @@
+/**
+ * Candidate browse queue — unique ids, neighbors, profile paths.
+ *
+ * node candidate-browse.test.mjs
+ */
+import assert from 'node:assert/strict'
+import { uniqueBrowseEntries, neighborsOf, candidatePath } from './src/lib/candidateBrowse.js'
+
+const rows = [
+ { userId: 'a', name: 'Ada', stage: 'Interview', jobTitle: 'Backend' },
+ { user_id: 'a', name: 'Ada duplicate application' },
+ { userId: '', name: 'Skipped' },
+ { userId: 'b', email: 'b@example.com' },
+ { userId: 'c', name: 'Chris', job_title: 'Design' },
+]
+
+const entries = uniqueBrowseEntries(rows)
+assert.deepEqual(entries.map((row) => row.userId), ['a', 'b', 'c'])
+assert.equal(entries[0].name, 'Ada')
+assert.equal(entries[0].stage, 'Interview')
+assert.equal(entries[1].name, 'b@example.com')
+assert.equal(entries[2].jobTitle, 'Design')
+
+const mid = neighborsOf(entries, 'b')
+assert.equal(mid.index, 1)
+assert.equal(mid.total, 3)
+assert.equal(mid.prev.userId, 'a')
+assert.equal(mid.next.userId, 'c')
+
+const first = neighborsOf(entries, 'a')
+assert.equal(first.prev, null)
+assert.equal(first.next.userId, 'b')
+
+const missing = neighborsOf(entries, 'z')
+assert.equal(missing.index, -1)
+assert.equal(missing.prev, null)
+assert.equal(missing.next, null)
+
+assert.equal(candidatePath('user/1'), '/candidate/user%2F1')
+assert.equal(candidatePath('abc', 'Resume'), '/candidate/abc?tab=Resume')
+
+console.log('All candidate browse checks passed')
diff --git a/frontend/candidate-profile.test.mjs b/frontend/candidate-profile.test.mjs
new file mode 100644
index 0000000..e411896
--- /dev/null
+++ b/frontend/candidate-profile.test.mjs
@@ -0,0 +1,198 @@
+/* Candidate workspace integration + responsive checks. Start Vite first, then:
+ node candidate-profile.test.mjs
+ ATS_BASE_URL / CHROME_PATH override the local server/browser. All API calls
+ are intercepted with fixtures; this test never writes to the real backend.
+ ATS_SCREENSHOT_DIR optionally saves desktop and mobile preview images. */
+import assert from 'node:assert/strict'
+import { existsSync, mkdirSync } from 'node:fs'
+import { join } from 'node:path'
+import puppeteer from 'puppeteer-core'
+
+const base = process.env.ATS_BASE_URL || 'http://127.0.0.1:5173'
+const chrome = process.env.CHROME_PATH || [
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ '/usr/bin/google-chrome', '/usr/bin/chromium',
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
+].find(existsSync)
+assert(chrome, 'Set CHROME_PATH to an installed Chrome browser')
+const permissions = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'settings', 'requisitions'].flatMap((module) => ['view', 'create', 'edit', 'manage'].map((action) => `${module}.${action}`))
+const user = { id: 'test-recruiter', name: 'Adeel Haider', email: 'adeel@example.com', role_name: 'hr_administrator', permissions }
+const fixture = {
+ user_id: 'test-candidate', inbox_id: 101, message_id: 'test-message', name: 'Sarah Khan',
+ email: 'sarah.khan@example.com', phone: '+92 300 0000000', city: 'Lahore, Pakistan',
+ currentCompany: 'Techvista Solutions', current_title: 'Digital Marketing Specialist',
+ experience: '5 years', education: 'BBA - Marketing, LUMS', linkedin_url: 'https://example.com/sarah',
+ job_title: 'Marketing Manager', assigned_job_post_id: 'job-marketing', source: 'LinkedIn',
+ application_status: 'PENDING', applied: '2026-09-09T09:00:00Z', rating: 4, favorite: false,
+ professional_summary: 'Results-driven digital marketing professional with 5 years of experience in developing and executing data-driven marketing strategies. Experienced in increasing brand visibility, improving customer engagement, and delivering measurable growth through SEO, PPC, and social media campaigns.',
+ matched_keywords: ['Digital Marketing', 'SEO', 'Google Ads', 'Social Media', 'Content Marketing', 'Analytics', 'Brand Strategy'],
+ recruiter: 'Adeel Haider', documents: [
+ { name: 'Sarah_Khan_Resume.pdf', path: 'Email/test/resume.pdf' },
+ { name: 'Portfolio.pdf', path: 'Email/test/portfolio.pdf' },
+ { name: 'Cover_Letter.pdf', path: 'Email/test/cover.pdf' },
+ ],
+ previous_applications: [
+ { source: 'inbox', inbox_id: 101, message_id: 'test-message', job_post_id: 'job-marketing', job_title: 'Marketing Manager', status: 'PENDING', applied_at: '2026-09-09' },
+ { source: 'inbox', inbox_id: 99, message_id: 'old-message', job_post_id: 'job-social', job_title: 'Social Media Specialist', status: 'CLOSED', applied_at: '2026-08-03' },
+ { source: 'manual', manual_upload_candidate_id: 'manual-old', job_title: 'Digital Marketing Executive', status: 'REJECTED', applied_at: '2026-07-20' },
+ ],
+ notes: [{ id: 'note-1', note: 'Relevant paid-media experience. Explore budget ownership during screening.', created_by_name: 'Adeel Haider', created_at: '2026-09-09T11:30:00Z' }],
+ interviews: [{ id: 'interview-1', interview_type: 'Phone Screen', interview_date: '2026-09-11T09:00:00Z', interview_status: 'Scheduled' }],
+ activity: [{ id: 'activity-1', activity_type: 'Recruiter assigned', description: 'Adeel Haider is responsible for the current application.', activity_date: '2026-09-10T09:00:00Z' }],
+}
+const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-sandbox'], defaultViewport: { width: 1536, height: 1100 } })
+const page = await browser.newPage()
+const errors = []
+const writes = []
+const reads = []
+let candidate = structuredClone(fixture)
+let activeUser = user
+let failDetail = false
+page.on('pageerror', (error) => errors.push(error.message))
+await page.setRequestInterception(true)
+page.on('request', async (request) => {
+ if (!['fetch', 'xhr', 'preflight'].includes(request.resourceType()) && new URL(request.url()).port !== '8000') return request.continue()
+ const path = new URL(request.url()).pathname
+ reads.push({ path, url: request.url() })
+ let data = []
+ if (request.method() === 'OPTIONS') return request.respond({ status: 204, headers: { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*', 'access-control-allow-methods': '*' } })
+ if (path === '/users/me') data = activeUser
+ if (path === '/candidate/fetch') {
+ const userId = new URL(request.url()).searchParams.get('user_id')
+ if (!userId) data = []
+ else if (userId === 'test-candidate-b') data = { ...candidate, user_id: 'test-candidate-b', name: 'Omar Ali' }
+ else if (userId === 'test-candidate-c') data = { ...candidate, user_id: 'test-candidate-c', name: 'Hina Raza' }
+ else data = candidate
+ }
+ if (path === '/forms/definitions') data = {}
+ if (path === '/s3/open') data = { url: `${base}/fixture-resume.pdf` }
+ if (path === '/fixture-resume.pdf') return request.respond({ status: 200, contentType: 'application/pdf', body: '%PDF-1.4\n%%EOF' })
+ if (request.method() !== 'GET') {
+ const body = JSON.parse(request.postData() || '{}')
+ writes.push({ path, body })
+ if (path === '/candidate/update') Object.assign(candidate, body)
+ if (path === '/candidate/stage') candidate.application_status = body.to_stage
+ if (path === '/notes/create') candidate.notes.push({ id: 'new-note', note: body.note, created_at: new Date().toISOString() })
+ }
+ return request.respond({ status: path === '/candidate/fetch' && failDetail ? 500 : 200, contentType: 'application/json', headers: { 'access-control-allow-origin': '*' }, body: JSON.stringify({ data, status_code: 200 }) })
+})
+await page.evaluateOnNewDocument((account) => {
+ localStorage.setItem('tf-auth', JSON.stringify({ access_token: 'fixture-token', refresh_token: 'fixture-refresh', expires_at: Date.now() + 3600000, data: account }))
+ sessionStorage.setItem('tf-candidate-browse', JSON.stringify({
+ entries: [
+ { userId: 'test-candidate', name: 'Sarah Khan', stage: 'Shortlist', jobTitle: 'Marketing Manager' },
+ { userId: 'test-candidate-b', name: 'Omar Ali', stage: 'Interview', jobTitle: 'Marketing Manager' },
+ { userId: 'test-candidate-c', name: 'Hina Raza', stage: 'Screening', jobTitle: 'Content Lead' },
+ ],
+ }))
+}, user)
+async function clickText(selector, text) {
+ const clicked = await page.evaluate((selector, text) => {
+ const button = [...document.querySelectorAll(selector)].find((item) => item.textContent.trim() === text)
+ if (!button) return false
+ button.click(); return true
+ }, selector, text)
+ assert(clicked, `Missing ${text}`)
+}
+async function load() {
+ await page.goto(`${base}/candidate/test-candidate`, { waitUntil: 'networkidle0' })
+ await page.waitForSelector('.cw-hero h1')
+}
+try {
+ await load()
+ assert.equal(await page.$eval('.cw-hero h1', (element) => element.textContent), 'Sarah Khan')
+ assert.equal(await page.$$eval('.cw-applications tbody tr', (rows) => rows.length), 3)
+ assert.ok(await page.$('.sidebar'), 'Candidate page keeps the app sidebar')
+ assert.equal(await page.$eval('.cw-browse-counter', (element) => element.textContent.trim()), '1 of 3')
+ assert.equal(writes.length, 0, 'Opening a candidate is read-only')
+ for (const width of [1536, 1280, 1024, 768, 390, 320]) {
+ await page.setViewport({ width, height: 1100 })
+ const overflow = await page.evaluate(() => [document.documentElement, document.querySelector('.content'), document.querySelector('.cand-page')].map((node) => node.scrollWidth - node.clientWidth))
+ assert(overflow.every((amount) => amount <= 1), `Overflow at ${width}px: ${overflow}`)
+ if (process.env.ATS_SCREENSHOT_DIR && [1536, 390].includes(width)) {
+ mkdirSync(process.env.ATS_SCREENSHOT_DIR, { recursive: true })
+ await page.screenshot({ path: join(process.env.ATS_SCREENSHOT_DIR, `candidate-${width}.png`), fullPage: true })
+ }
+ }
+ console.log('ok Profile data, 3-column desktop and mobile layouts (320–1536px)')
+ await page.setViewport({ width: 1536, height: 1100 })
+ await page.click('[aria-label="Next candidate"]')
+ await page.waitForFunction(() => document.querySelector('.cw-hero h1')?.textContent === 'Omar Ali')
+ assert.equal(await page.$eval('.cw-browse-counter', (element) => element.textContent.trim()), '2 of 3')
+ assert.match(page.url(), /\/candidate\/test-candidate-b/)
+ await page.click('[aria-label="Previous candidate"]')
+ await page.waitForFunction(() => document.querySelector('.cw-hero h1')?.textContent === 'Sarah Khan')
+ console.log('ok Previous / next walks the candidate list')
+ await clickText('.cand-page-actions button', 'Favorite')
+ await page.waitForFunction(() => document.querySelector('.cand-page-actions button').getAttribute('aria-pressed') === 'true')
+ await page.click('.cw-rating [role=radio]:nth-child(5)')
+ await page.waitForFunction(() => document.querySelector('.cw-rating').textContent.includes('5.0 / 5'))
+ assert(writes.some((write) => write.path === '/candidate/update' && write.body.rating === 5))
+ await clickText('.cw-action-grid button', 'Add Note')
+ await page.type('.candidate-dialog textarea', 'Follow up on campaign results.')
+ await clickText('.candidate-dialog button', 'Add Note')
+ await page.waitForSelector('.candidate-dialog', { hidden: true })
+ assert(writes.some((write) => write.path === '/notes/create' && write.body.note === 'Follow up on campaign results.'))
+ console.log('ok Favorite, rating and notes retain existing API requests')
+ await clickText('.cw-action-grid button', 'Schedule Interview')
+ await page.$eval('.candidate-dialog input[type=date]', (input) => {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set
+ setter.call(input, '2027-01-20'); input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ await clickText('.candidate-dialog button', 'Schedule Interview')
+ await page.waitForSelector('.candidate-dialog', { hidden: true })
+ assert(writes.some((write) => write.path === '/interview/create' && write.body.inbox_id === 101))
+ const downloadSession = await page.createCDPSession()
+ await downloadSession.send('Page.setDownloadBehavior', { behavior: 'deny' })
+ await clickText('.cw-hero-actions button', 'Download CV')
+ await page.waitForFunction(() => !document.querySelector('.cw-hero-actions button').disabled)
+ assert(reads.some((read) => read.path === '/s3/open' && new URL(read.url).searchParams.get('key') === 'Email/test/resume.pdf'))
+ assert(reads.some((read) => read.path === '/fixture-resume.pdf'))
+ assert(!reads.some((read) => read.path === '/documents/download'), 'S3 documents must not use the local-file download endpoint')
+ console.log('ok Interview scheduling and signed resume download')
+ await page.select('.cw-status-grid select', 'Interview')
+ const beforeCancel = writes.length
+ await clickText('.candidate-dialog button', 'Cancel')
+ assert.equal(writes.length, beforeCancel, 'Cancelling must not update stage')
+ await page.select('.cw-status-grid select', 'Interview')
+ await clickText('.candidate-dialog button', 'Save Stage')
+ await page.waitForSelector('.candidate-dialog', { hidden: true })
+ assert(writes.some((write) => write.path === '/candidate/stage' && write.body.to_stage === 'INTERVIEW'))
+ await clickText('.cw-action-grid button', 'Reject')
+ assert(await page.$eval('.candidate-dialog button[type=submit]', (button) => button.disabled), 'Rejection needs a reason')
+ await clickText('.candidate-dialog button', 'Cancel')
+ console.log('ok Stage confirmation, cancellation, rejection reason')
+ for (const tab of ['Resume', 'Interviews', 'Forms', 'Notes', 'Activity', 'Timeline', 'History', 'Overview']) {
+ await page.evaluate((label) => [...document.querySelectorAll('[role=tab]')].find((item) => item.textContent.startsWith(label)).click(), tab)
+ await page.waitForFunction((label) => document.querySelector('[role=tab][aria-selected=true]')?.textContent.startsWith(label), {}, tab)
+ }
+ await page.click('[role=tab][aria-selected=true]')
+ await page.keyboard.press('ArrowRight')
+ assert((await page.$eval('[role=tab][aria-selected=true]', (tab) => tab.textContent)).startsWith('Resume'))
+ assert(await page.$eval('[role=tabpanel]', (panel) => Boolean(document.getElementById(panel.getAttribute('aria-labelledby')))))
+ console.log('ok All tabs and keyboard navigation')
+ activeUser = { ...user, role_name: 'hiring_manager' }
+ await load()
+ assert.deepEqual(await page.$$eval('[role=tab]', (tabs) => tabs.map((tab) => tab.textContent.replace(/\d/g, '').trim())), ['Forms', 'Notes'])
+ assert.equal(await page.$('.cw-overview'), null)
+ console.log('ok Hiring manager restricted view')
+ activeUser = { ...user, permissions: ['candidates.view', 'interviews.view'] }
+ candidate = { user_id: 'test-candidate', name: 'Candidate with no attachments', notes: [], interviews: [], documents: [] }
+ await load()
+ assert.equal(await page.$('.cw-document'), null)
+ assert(await page.$eval('.cand-page-actions button', (button) => button.disabled))
+ assert(await page.$eval('.cw-status-grid select', (select) => select.disabled))
+ assert((await page.$eval('.cw-overview', (element) => element.textContent)).includes('No resume attached'))
+ console.log('ok Missing fields, missing attachments and read-only permissions')
+ activeUser = user
+ failDetail = true
+ await load()
+ await page.waitForFunction(() => document.querySelector('.cand-page').textContent.includes('Could not load this candidate'), { timeout: 15000 })
+ assert.equal(await page.$('.cw-overview'), null, 'Do not show stale candidate details after a failed load')
+ failDetail = false
+ await clickText('.cand-page button', 'Try again')
+ await page.waitForSelector('.cw-overview')
+ console.log('ok Load failure and retry')
+ assert.deepEqual(errors, [], 'No runtime errors')
+ console.log('All candidate workspace checks passed')
+} finally { await browser.close() }
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index d5182fc..bcd8615 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -8,6 +8,13 @@ server {
# CV / multipart uploads (MAX_PDF_SIZE_MB is 10; leave headroom for form fields).
client_max_body_size 25m;
+ # Resolve backend-api through Docker's embedded DNS on each request instead of
+ # once at startup. `docker compose up` recreates backend-api with a new IP;
+ # a static upstream keeps the old one and every API call 502s until nginx restarts.
+ # proxy_pass with a variable and no URI forwards the original request URI unchanged.
+ resolver 127.0.0.11 valid=10s ipv6=off;
+ set $backend_api http://backend-api:8000;
+
# Security headers on every response.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
@@ -23,7 +30,7 @@ server {
# SPA page roots that also prefix API calls — sub-path required.
location ~ ^/(jobs|inbox|pipeline|tasks|assessments|offers|managers|analytics|notifications)/ {
- proxy_pass http://backend-api:8000;
+ proxy_pass $backend_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -37,8 +44,8 @@ server {
}
# API-only prefixes (no SPA page at the bare path).
- location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms)(/|$) {
- proxy_pass http://backend-api:8000;
+ location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms|department)(/|$) {
+ proxy_pass $backend_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
diff --git a/frontend/package.json b/frontend/package.json
index f06b864..2bd2e46 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,9 +14,11 @@
"test:format": "node format.test.mjs",
"test:inbox": "node inbox-loading.test.mjs",
"test:candidates": "node candidates-table.test.mjs",
+ "test:profile": "node candidate-profile.test.mjs",
"test:cvbank": "node cvbank.test.mjs",
+ "test:browse": "node candidate-browse.test.mjs",
"test:mobile": "node mobile.test.mjs",
- "verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs"
+ "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",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 9c52297..4485be8 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,5 +1,5 @@
import { lazy } from 'react'
-import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
+import { BrowserRouter, Navigate, Route, Routes, useParams, useSearchParams } from 'react-router-dom'
import AuthProvider from './auth/AuthProvider'
import RequireAuth from './auth/RequireAuth'
@@ -34,6 +34,7 @@ const SCREENS = {
assessments: lazy(() => import('./screens/Assessments')),
offers: lazy(() => import('./screens/Offers')),
managers: lazy(() => import('./screens/Managers')),
+ departments: lazy(() => import('./screens/Departments')),
calendar: lazy(() => import('./screens/Calendar')),
reports: lazy(() => import('./screens/Reports')),
analytics: lazy(() => import('./screens/Analytics')),
@@ -47,6 +48,14 @@ const SCREENS = {
// Detail pages live outside the ROUTES table (no sidebar entry, parameterized path).
const CandidatePage = lazy(() => import('./screens/CandidatePage'))
+function LegacyCandidateRedirect() {
+ const { userId } = useParams()
+ const [params] = useSearchParams()
+ const tab = params.get('tab')
+ const to = `/candidate/${encodeURIComponent(userId)}`
+ return
+}
+
export default function App() {
return (
@@ -95,6 +104,14 @@ export default function App() {
}
/>
+
+
+
+ }
+ />
} />
diff --git a/frontend/src/api/departments.js b/frontend/src/api/departments.js
new file mode 100644
index 0000000..742d47c
--- /dev/null
+++ b/frontend/src/api/departments.js
@@ -0,0 +1,51 @@
+import { request } from '../lib/apiClient'
+
+/* ============================================================
+ departments.js — backend/department/app.py routes.
+
+ created_by / updated_by come from the JWT on the server — do not send them.
+ `location` is a string[]; the form edits it as comma-separated text.
+ ============================================================ */
+
+export function list({ search, isActive, top, skip } = {}) {
+ return request('/department/fetch', {
+ params: { search: search || undefined, is_active: isActive, top, skip },
+ })
+}
+
+export function getById(recordId) {
+ return request('/department/fetch', { params: { record_id: recordId } })
+}
+
+export function create(body) {
+ return request('/department/create', { method: 'POST', body })
+}
+
+export function update(recordId, body) {
+ return request('/department/update', { method: 'PUT', params: { record_id: recordId }, body })
+}
+
+/** Department Head picker — `{data:[{id,name,email}]}`. The server defaults
+ * `role_id` to the department_head role when it is not sent. */
+export function listHeads({ roleId, search, top, skip } = {}) {
+ return request('/department/heads/fetch', {
+ params: { role_id: roleId, search: search || undefined, top, skip },
+ })
+}
+
+/** Region / Location picker — `{data:["Karachi - Pakistan", …]}` from global_cities.py. */
+export function listLocations({ search } = {}) {
+ return request('/department/locations/fetch', { params: { search: search || undefined } })
+}
+
+/** Active departments for pickers (Requisitions "From (Dept.)") — `{data:[{id,name}]}`. */
+export function listNames({ search } = {}) {
+ return request('/department/names', { params: { search: search || undefined } })
+}
+
+export function toRows(res) {
+ const data = res?.data
+ if (Array.isArray(data)) return data
+ if (data) return [data]
+ return []
+}
diff --git a/frontend/src/api/forms.js b/frontend/src/api/forms.js
index e5d310f..8168951 100644
--- a/frontend/src/api/forms.js
+++ b/frontend/src/api/forms.js
@@ -5,8 +5,8 @@ import { request } from '../lib/apiClient'
The digitized hiring forms: Annexure A (Employee Requisition), and the two
halves of Annexure E — Interview Analysis (technical + behavioral) and
- Cultural Fit. Dual-key like assessments: exactly one of inbox_id /
- manual_upload_candidate_id.
+ HR Evaluation (form_type stays "cultural_fit"). Dual-key like assessments:
+ exactly one of inbox_id / manual_upload_candidate_id.
Permissioned with the interviews module tags (interviews.view to read,
interviews.create to fill, interviews.edit to amend). Creating is
diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js
index 1278337..021a942 100644
--- a/frontend/src/api/jobs.js
+++ b/frontend/src/api/jobs.js
@@ -90,6 +90,7 @@ export function toJobView(row) {
id: row.id,
title: row.title,
department: row.department,
+ departmentId: row.department_id || null,
location: row.location,
type: row.employment_type,
vacancies: row.vacancies,
diff --git a/frontend/src/api/requisitions.js b/frontend/src/api/requisitions.js
index 28026e3..3415fce 100644
--- a/frontend/src/api/requisitions.js
+++ b/frontend/src/api/requisitions.js
@@ -22,6 +22,11 @@ export function list() {
return request('/forms/requisition/fetch')
}
+/** GET /forms/requisition/open-count — `{data:{open}}`: unlinked, or linked job still open. */
+export function countOpen() {
+ return request('/forms/requisition/open-count')
+}
+
export function getById(formId) {
return request('/forms/requisition/fetch', { params: { form_id: formId } })
}
diff --git a/frontend/src/api/s3.js b/frontend/src/api/s3.js
index d77043d..b0f0579 100644
--- a/frontend/src/api/s3.js
+++ b/frontend/src/api/s3.js
@@ -12,9 +12,17 @@ export function openUrl(key, { expiresIn } = {}) {
})
}
+function asList(value) {
+ return Array.isArray(value) ? value : []
+}
+
/** First comma-separated stored path — inbox_messages.file_path can list several. */
export function firstKey(filePath) {
- return (filePath || '').split(',')[0].trim() || null
+ if (Array.isArray(filePath)) return firstKey(filePath[0])
+ if (filePath && typeof filePath === 'object') {
+ return firstKey(filePath.url || filePath.path || filePath.key)
+ }
+ return String(filePath || '').split(',')[0].trim() || null
}
/** S3 object address (virtual-hosted URL) or record-scoped key Email|Manual|Form|Temp/... */
@@ -37,9 +45,17 @@ export function canOpen(filePath) {
/** First usable S3/http ref on a candidate or inbox payload. */
export function resumeKeyFrom(item) {
if (!item) return null
- const fromFiles = (item.files || []).map((f) => f.url).find(Boolean)
+ if (typeof item.files === 'string') {
+ const key = firstKey(item.files)
+ if (key) return key
+ }
+ const fromFiles = asList(item.files).map((f) => (typeof f === 'string' ? f : f?.url || f?.path)).find(Boolean)
if (fromFiles) return firstKey(fromFiles)
- const fromDocs = (item.documents || []).map((d) => d.path).find(Boolean)
+ if (typeof item.documents === 'string') {
+ const key = firstKey(item.documents)
+ if (key) return key
+ }
+ const fromDocs = asList(item.documents).map((d) => (typeof d === 'string' ? d : d?.path || d?.url)).find(Boolean)
if (fromDocs) return firstKey(fromDocs)
return firstKey(item.file_path || item.filePath)
}
diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx
index 13ba098..5aab174 100644
--- a/frontend/src/app/AppLayout.jsx
+++ b/frontend/src/app/AppLayout.jsx
@@ -20,6 +20,7 @@ export default function AppLayout() {
const badges = useBadges()
const routeKey = location.pathname.split('/')[1] || 'dashboard'
+ const candidateView = routeKey === 'candidate'
const route = ROUTE_BY_PATH[routeKey]
useRouteMeta(route)
@@ -38,7 +39,7 @@ export default function AppLayout() {
}, [location.pathname, setNavOpen])
return (
-