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 @@ + + + + + + + + + + + + + + +
+ +
+ + + Utopia BrandsHR Portal + + + +
+ +
+ +
+
+ +
+
+ +
+ +
Candidates / Ada Lovelace
+
+ +
+
+ + +
+ AL +
+
+
+
+

Ada Lovelace

+ {{stage}} +
+ +
+
+ + +
+
+
+
Applied forSenior Backend Engineer
+
Applied onMar 12, 2026
+
SourceCareers page
+
Current companyMeridian Systems
+
Experience6 years
+
EducationMSc Computer Science
+
Total applications{{appCount}}
+
+
+
+ + +
+
+ + + +
+
+ + + +
+ +
+
+

Candidate Information

+
+
Full name
Ada Lovelace
+
Email
ada.lovelace@example.com
+
Phone
+1 (555) 214-7788
+
Location
Austin, TX
+
Current company
Meridian Systems
+
Current title
Senior Backend Engineer
+
Experience
6 years
+
Education
MSc Computer Science — Imperial College London
+ +
Notice period
{{noticePeriod}}
+
Expected salary
{{expectedSalary}}
+
+
+
+

Skills & Tags

+
+ PythonFastAPIPostgreSQLDockerKubernetesREST APIsKafkaAWS +
+
+
+ +
+
+
+

Applications ({{appCount}})

+ + + +
+
+ + + + + + + + + + + + +
Job titleApplied onStatusActions
{{a.title}}{{a.sub}}{{a.when}}{{a.status}}
+
+
+ +
+

Professional Summary

+

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
+
+
+ + +
+
    +
  1. Application received

    Applied via Careers page

  2. +
  3. AI screening completed

    Match score: 82%

  4. +
  5. Interview scheduled

    Technical — System Design

  6. +
+
+
+ + +
+ +
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 ( -
+
Skip to content 0 &&
Candidates
} {candidates.map((c) => ( -
go('/candidates', { openCandidate: c.id })}> +
openCandidateProfile(navigate, c.id, candidates.map((row) => ({ userId: row.id, name: row.name })))}>
{c.name}
diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index b3a3a11..28dd773 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -44,6 +44,7 @@ export const ROUTES = [ { path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' }, { path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' }, { path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: 'jobs.view' }, + { path: 'departments', title: 'Departments', icon: 'layers', group: 'Hiring', permission: 'department.view', tag: 'NEW' }, { path: 'calendar', title: 'Calendar', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' }, // --- Insights --- diff --git a/frontend/src/auth/permissions.js b/frontend/src/auth/permissions.js index fb454bf..44158b4 100644 --- a/frontend/src/auth/permissions.js +++ b/frontend/src/auth/permissions.js @@ -16,7 +16,7 @@ export const MODULES = [ 'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments', 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', - 'talent', 'requisitions', + 'talent', 'requisitions', 'department', ] export const ACTIONS = [ diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx index 226b5b5..d33eebe 100644 --- a/frontend/src/components/ErrorBoundary.jsx +++ b/frontend/src/components/ErrorBoundary.jsx @@ -25,6 +25,11 @@ export default class ErrorBoundary extends Component { This screen hit an unexpected error. The rest of the app is fine — try again, or head back to the dashboard. + {this.state.error?.message ? ( +
+ {this.state.error.message} +
+ ) : null}
+ {index + 1} of {total} + +
+ ) +} diff --git a/frontend/src/screens/CandidateForms.jsx b/frontend/src/screens/CandidateForms.jsx index 740b13e..8384db4 100644 --- a/frontend/src/screens/CandidateForms.jsx +++ b/frontend/src/screens/CandidateForms.jsx @@ -1,7 +1,9 @@ -/* The Forms tab of the candidate profile modal — Interview Analysis + Cultural - Fit (the two halves of Annexure E), and the Offer (Annexure J fields on the - offers table). Employee Requisition (Annexure A) lives on the Requisitions - screen, not on a candidate. +/* The Forms tab of the candidate profile modal — Interview Analysis + HR + Evaluation (the two halves of Annexure E; form_type stays "cultural_fit" — + only its title/criteria grew to the fuller HR Evaluation section in + revision 2), and the Offer (Annexure J fields on the offers table). + Employee Requisition (Annexure A) lives on the Requisitions screen, not on + a candidate. Field and criterion labels are rendered from GET /forms/definitions — the backend is the single authority for the paper forms' exact wording. The @@ -161,7 +163,7 @@ export default function CandidateFormsTab({ userId, live }) { } const segTabs = [ { key: 'interview_analysis', label: 'Interview Analysis' }, - { key: 'cultural_fit', label: 'Cultural Fit' }, + { key: 'cultural_fit', label: 'HR Evaluation' }, ...(!isManager ? [{ key: 'offer', label: 'Offer' }] : []), ] @@ -233,7 +235,7 @@ function SummaryStrip({ summary, evalCount }) {
- + { + if (manager) { + const res = await candidatesApi.listForManager({ limit: 200, offset: 0 }) + const rows = Array.isArray(res?.data) ? res.data : [] + return uniqueBrowseEntries(rows.map((row) => ({ + ...row, + stage: pipelineApi.STAGE_FROM_STATUS[String(row.application_status || '').toUpperCase()] || null, + }))) + } + const res = await candidatesApi.list({ limit: 100, offset: 0 }) + const rows = Array.isArray(res?.data) ? res.data.map(candidatesApi.toApplicationListView) : [] + return uniqueBrowseEntries(rows) + }, + enabled: stored.length === 0 && Boolean(userId), + staleTime: 30_000, + }) + const entries = stored.length ? stored : (fallback.data ?? []) + return neighborsOf(entries, userId) +} + export default function CandidatePage() { const { userId } = useParams() const navigate = useNavigate() + const [searchParams] = useSearchParams() + const browse = useCandidateBrowse(userId) + const tab = searchParams.get('tab') || undefined + + function goTo(id) { + if (!id || String(id) === String(userId)) return + navigate(candidatePath(id, tab)) + } + + useEffect(() => { + function onKey(event) { + if (!event.altKey || event.metaKey || event.ctrlKey) return + const tag = event.target?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable) return + if (event.key === 'ArrowLeft' && browse.prev) { + event.preventDefault() + goTo(browse.prev.userId) + } + if (event.key === 'ArrowRight' && browse.next) { + event.preventDefault() + goTo(browse.next.userId) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [browse.prev, browse.next, userId, tab]) return ( (window.history.length > 1 ? navigate(-1) : navigate('/candidates'))} /> ) diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index cd4ff2e..bf6a63e 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -1,5 +1,5 @@  -import { useMemo, useState } from 'react' +import { useId, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -19,6 +19,8 @@ import * as formsApi from '../api/forms' import * as pipelineApi from '../api/pipeline' import * as s3Api from '../api/s3' import CandidateFormsTab from './CandidateForms' +import CandidateWorkspaceOverview, { CandidateWorkspaceHero } from './CandidateWorkspace' +import { CandidateBrowseNav } from './CandidateBrowse' import { PreviousApplications, ReappliedBadge, candidateApplicationsOf } from '../components/ReapplicantHistory' import { fmtDate, fmtTime, toDate } from '../lib/format' import { companies, moneyK, pick } from '../data/seed' @@ -108,18 +110,31 @@ function useProfileWrite({ userId, mutationFn, success, error, onDone }) { */ export default function CandidateProfile({ candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch, - variant = 'modal', + variant = 'modal', browse = null, onBrowse, }) { const { toast } = useToast() const { can, user } = useAuth() const isManager = isHiringManager(user) const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS - const [searchParams] = useSearchParams() + const [searchParams, setSearchParams] = useSearchParams() const [tab, setTab] = useState(() => tabFromSearch( variant === 'page' ? searchParams.get('tab') : null, visibleTabs, isManager ? 'Forms' : 'Overview', )) + function changeTab(next) { + setTab(next) + if (variant !== 'page') return + setSearchParams((prev) => { + const params = new URLSearchParams(prev) + params.set('tab', next) + return params + }, { replace: true }) + } + const tabId = useId() + const [dialog, setDialog] = useState(null) + const [stageReason, setStageReason] = useState('') + const openAction = (type, stage) => { setStageReason(''); setDialog({ type, stage }) } const { data: interviews = [] } = useQuery(seedQuery('interviews')) const isLive = Boolean(c.userId) @@ -130,7 +145,7 @@ export default function CandidateProfile({ // employer" changed every repaint. Fixed per candidate. const priorCompany = useMemo(() => pick(companies), []) - const candidateInterviews = interviews.filter((i) => i.candidateId === c.id) + const candidateInterviews = (Array.isArray(interviews) ? interviews : []).filter((i) => i.candidateId === c.id) // The application row interviews/activity/feedback attach to. Detail mode // flattens every application the candidate owns; writes land on the first, @@ -153,7 +168,7 @@ export default function CandidateProfile({ const setFavorite = useProfileWrite({ userId: c.userId, mutationFn: (next) => candidatesApi.update(c.userId, { favorite: next }), - success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'), + success: (next) => (next ? `${live?.name || c.name || 'Candidate'} added to favorites` : 'Removed from favorites'), }) // Same PATCH as favorite: the server writes rating onto every inbox row the @@ -215,6 +230,23 @@ export default function CandidateProfile({ }, }) + const moveStage = useProfileWrite({ + userId: c.userId, + mutationFn: () => pipelineApi.changeStage({ + inboxId: live?.inbox_id ?? undefined, + manualUploadId: live?.inbox_id ? undefined : live?.manual_upload_candidate_id, + toStage: pipelineApi.STATUS_FROM_STAGE[dialog.stage], + changeReason: stageReason.trim() || 'Updated from candidate profile', + }), + success: () => `Moved to ${dialog.stage}`, + onDone: () => { + setDialog(null) + qc.invalidateQueries({ queryKey: qk.pipeline.all() }) + qc.invalidateQueries({ queryKey: qk.forms.all() }) + qc.invalidateQueries({ queryKey: qk.analytics.all() }) + }, + }) + // The hero experience chip: live experience is free text ("6 years"), seed is // a number. Render nothing rather than a bare "yrs exp". const expRaw = live?.experience ?? c.experience @@ -224,7 +256,7 @@ export default function CandidateProfile({ const counts = live && { Interview: live.interviews?.length ?? 0, - Forms: (formsQuery.data?.data ?? []).filter((r) => r.form_type !== 'requisition').length, + Forms: (Array.isArray(formsQuery.data?.data) ? formsQuery.data.data : []).filter((r) => r.form_type !== 'requisition').length, Notes: live.notes?.length ?? 0, Activity: live.activity?.length ?? 0, Documents: live.documents?.length ?? 0, @@ -239,6 +271,7 @@ export default function CandidateProfile({ ) : detail.isError ? ( {friendlyAuthError(detail.error, 'Please try again.')} + ) : !live ? ( This candidate is no longer in the pipeline. @@ -294,7 +327,7 @@ export default function CandidateProfile({ const body = ( <> -
+ {variant === 'page' ? :
@@ -339,19 +372,25 @@ export default function CandidateProfile({ {live?.professional_summary ?
{live.professional_summary}
: null}
) : null} -
+
} -
+
({ key: t, label: t, count: counts ? counts[t] : undefined }))} + onChange={changeTab} + className={variant === 'page' ? 'tabs' : 'tabs tabs-wrap'} + tabs={visibleTabs.map((t) => ({ key: t, label: t === 'Interview' ? 'Interviews' : t, count: counts && ['Interview', 'Forms', 'Notes'].includes(t) ? counts[t] : undefined }))} />
-
- {tab === 'Overview' && (guard || (live ? ( +
+ {tab === 'Overview' && (guard || (variant === 'page' ? setRating.mutate(n)} + atsScore={atsScore} recommendation={recommendation} + atsAction={canRerunAts && can('candidates.create') ? : null} + /> : live ? ( <>
@@ -406,11 +445,11 @@ export default function CandidateProfile({ )} - {live.job_posts?.length > 0 && ( + {Array.isArray(live.job_posts) && live.job_posts.length > 0 && ( <>
Suggested Roles
- {live.job_posts.map((j) => {j.title})} + {live.job_posts.filter(Boolean).map((j) => {j.title})}
)} @@ -432,7 +471,7 @@ export default function CandidateProfile({
Skills
-
{c.skills.map((s) => {s})}
+
{(Array.isArray(c.skills) ? c.skills : []).map((s) => {s})}
)))} @@ -623,17 +662,34 @@ export default function CandidateProfile({ return (
-
- Candidates / {live?.name || c.name || '…'} + + Candidates / {live?.name || c.name || '…'} +
+
+ {!isManager && } + {onBrowse && }
- {actions &&
{actions}
} -
-
-
{body}
+ {body} + {dialog && live && { if (!moveStage.isPending) setDialog(null) }} + > + {dialog.type === 'interview' && setDialog(null)} />} + {dialog.type === 'note' && setDialog(null)} />} + {dialog.type === 'share' &&
event.target.select()} />

Copy this link to share with a member of your hiring team.

} + {dialog.type === 'stage' &&
{ event.preventDefault(); if (!moveStage.isPending) moveStage.mutate() }}> +

{live.job_title || 'Current application'} · Currently {stageLabel}

+
+