diff --git a/backend/inbox/models.py b/backend/inbox/models.py
index f60d92f..25af7c2 100644
--- a/backend/inbox/models.py
+++ b/backend/inbox/models.py
@@ -214,8 +214,10 @@ class Inbox(SQLModel, table=True):
return or_(Users.name.ilike(pattern), Users.email.ilike(pattern))
@classmethod
- async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None):
+ async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None,job_post_ids=None):
try:
+ if job_post_ids is not None and not list(job_post_ids):
+ return []
options=[selectinload(cls.messages)]
if user_id:
options.extend([
@@ -234,6 +236,11 @@ class Inbox(SQLModel, table=True):
qry = qry.where(cls.user_id == user_id)
if search:
qry = qry.where(cls._candidate_search_filter(search))
+ if job_post_ids is not None:
+ qry = (
+ qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
+ .where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
+ )
# Most-recent-first is the list contract; id breaks ties so a page
# boundary can't drop or repeat a row when created_at collides.
qry = qry.order_by(cls.created_at.desc(), cls.id.desc())
@@ -247,9 +254,11 @@ class Inbox(SQLModel, table=True):
raise HTTPException(status_code=500,detail=str(e))
@classmethod
- async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None):
+ async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None,job_post_ids=None):
"""Result-set size for the same predicate get_candidate_profile pages over."""
try:
+ if job_post_ids is not None and not list(job_post_ids):
+ return 0
qry = (
select(func.count())
.select_from(cls)
@@ -261,6 +270,11 @@ class Inbox(SQLModel, table=True):
qry = qry.where(cls.user_id == user_id)
if search:
qry = qry.where(cls._candidate_search_filter(search))
+ if job_post_ids is not None:
+ qry = (
+ qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
+ .where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
+ )
result = await session.execute(qry)
return result.scalar_one()
except Exception as e:
diff --git a/backend/job/app.py b/backend/job/app.py
index feee8fc..3920218 100644
--- a/backend/job/app.py
+++ b/backend/job/app.py
@@ -957,7 +957,9 @@ async def fetch_candidate(
)
- total=await service.count_candidates(user_id=user_id,search=search) if isinstance(data,list) else 1
+ total=await service.count_candidates(
+ user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,
+ ) if isinstance(data,list) else 1
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py
index 629893d..262dd25 100644
--- a/backend/job/candidate/models.py
+++ b/backend/job/candidate/models.py
@@ -390,16 +390,20 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
return result.scalars().first()
@classmethod
- async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None):
+ async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
"""Newest applications with a user + job for Talent Pool (manual / form)."""
from users.models import Users
+ if job_post_ids is not None and not list(job_post_ids):
+ return []
statement = (
select(cls)
.join(Users, cls.user_id == Users.id)
.where(cls.user_id.is_not(None), cls.job_post_id.is_not(None))
.order_by(cls.created_at.desc())
)
+ if job_post_ids is not None:
+ statement = statement.where(cls.job_post_id.in_(list(job_post_ids)))
if search:
like = f"%{search.strip()}%"
statement = statement.where(
diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py
index 1322b17..8a7e1a8 100644
--- a/backend/job/candidate/views.py
+++ b/backend/job/candidate/views.py
@@ -32,7 +32,7 @@ from job.history.views import HistoryRecorder
from job.notes.serializers import serialize_note
from job.candidate.plugins import extract_candidate_email
from users.models import Users
-from users.permissions import is_hiring_manager
+from users.permissions import is_hiring_manager,sees_all_candidates
from employment_agent.plugins import parse_phone
load_dotenv()
@@ -42,6 +42,7 @@ MANUAL_UPLOAD_TO_ADDRESS=os.getenv(
"MANUAL_UPLOAD_TO_ADDRESS","manual-cv-upload@hr-ats.local"
)
MANAGER_SCOPE_DETAIL="You can only access candidates allocated to jobs opened from your requisitions"
+CREATOR_SCOPE_DETAIL="You can only access candidates allocated to jobs you created"
async def assigned_job_ids_for_user(session,user_id):
@@ -77,15 +78,48 @@ async def job_id_for_application(session,inbox_id=None,manual_id=None):
return None,None
+async def owned_job_ids_for_candidate_scope(session,current_user):
+ """Job ids this user may see, or None when the list is unscoped.
+
+ Hiring manager → requisition / assigned-manager jobs.
+ candidates.manage or admin → None (all applications).
+ Otherwise → job_posts.created_by = this user. Never role_id.
+ """
+ if is_hiring_manager(current_user):
+ return await JobPosts.ids_for_manager(session,current_user.get("id"))
+ if sees_all_candidates(current_user):
+ return None
+ return await JobPosts.ids_for_creator(session,current_user.get("id"))
+
+
+async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post_id=None):
+ """None = unscoped list. [] = nothing visible. Else UUID list for the query."""
+ owned=await owned_job_ids_for_candidate_scope(session,current_user)
+ requested=JobPosts._as_uuid(assigned_job_post_id) if assigned_job_post_id is not None else None
+ if owned is None:
+ return [requested] if requested else None
+ if requested is not None:
+ return [requested] if requested in set(owned) else []
+ return list(owned)
+
+
+def _scope_detail(current_user):
+ if is_hiring_manager(current_user):
+ return MANAGER_SCOPE_DETAIL
+ return CREATOR_SCOPE_DETAIL
+
+
async def assert_manager_candidate_access(
session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None,
):
- """Hiring managers may only touch applications on jobs they own."""
- if not is_hiring_manager(current_user):
+ """Row access: hiring-manager jobs, unscoped (manage/admin), or jobs this user created."""
+ if not is_hiring_manager(current_user) and sees_all_candidates(current_user):
return
- owned=set(await JobPosts.ids_for_manager(session,current_user.get("id")))
+ owned=await owned_job_ids_for_candidate_scope(session,current_user)
+ owned=set(owned or [])
+ detail=_scope_detail(current_user)
if not owned:
- raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
+ raise HTTPException(status_code=403,detail=detail)
job_id=JobPosts._as_uuid(job_post_id) if job_post_id is not None else None
uid=user_id
if job_id is None and (inbox_id is not None or manual_id is not None):
@@ -94,9 +128,9 @@ async def assert_manager_candidate_access(
candidate_jobs=await assigned_job_ids_for_user(session,uid)
if candidate_jobs & owned:
return
- raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
+ raise HTTPException(status_code=403,detail=detail)
if job_id is None or job_id not in owned:
- raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
+ raise HTTPException(status_code=403,detail=detail)
async def parse_linkedin_url_from_cv(resume_text) -> str | None:
@@ -789,21 +823,32 @@ class CandidateView:
try:
if not user_id and is_hiring_manager(current_user):
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
- if user_id and is_hiring_manager(current_user):
+ if user_id:
await assert_manager_candidate_access(
self.session,current_user,user_id=user_id,
)
detail=bool(user_id)
- rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=limit,offset=offset,search=search)
+ list_job_ids=None
+ if not detail:
+ list_job_ids=await job_post_ids_for_candidate_list(
+ self.session,current_user,assigned_job_post_id=assigned_job_post_id,
+ )
+ if list_job_ids is not None and not list_job_ids:
+ return []
+ rows=await Inbox.get_candidate_profile(
+ session=self.session,user_id=user_id,limit=limit,offset=offset,search=search,
+ job_post_ids=list_job_ids,
+ )
if detail:
records=rows if isinstance(rows,list) else ([rows] if rows else [])
- if records and is_hiring_manager(current_user):
- owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id")))
+ owned=await owned_job_ids_for_candidate_scope(self.session,current_user)
+ if records and owned is not None:
+ owned_set=set(owned)
kept=[]
for rec in records:
msg=getattr(rec,"messages",None)
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
- if jid and jid in owned:
+ if jid and jid in owned_set:
kept.append(rec)
if kept:
return await self.attach_profile_detail(kept)
@@ -815,10 +860,10 @@ class CandidateView:
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
if not manual:
return []
- if is_hiring_manager(current_user):
- owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id")))
- if not manual.job_post_id or manual.job_post_id not in owned:
- raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
+ if owned is not None:
+ owned_set=set(owned)
+ if not manual.job_post_id or manual.job_post_id not in owned_set:
+ raise HTTPException(status_code=403,detail=_scope_detail(current_user))
user=await Users.get_user_by_id(self.session,user_id)
job_post=None
if manual.job_post_id:
@@ -841,7 +886,7 @@ class CandidateView:
if not isinstance(inbox_payloads,list):
inbox_payloads=[inbox_payloads] if inbox_payloads else []
manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
- self.session,limit=limit,offset=0,search=search,
+ self.session,limit=limit,offset=0,search=search,job_post_ids=list_job_ids,
)
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
manual_payloads=[]
@@ -902,9 +947,16 @@ class CandidateView:
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
- async def count_candidates(self,user_id=None,search=None):
+ async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None):
try:
- return await Inbox.count_candidate_profiles(session=self.session,user_id=user_id,search=search)
+ job_post_ids=None
+ if not user_id:
+ job_post_ids=await job_post_ids_for_candidate_list(
+ self.session,current_user,assigned_job_post_id=assigned_job_post_id,
+ )
+ return await Inbox.count_candidate_profiles(
+ session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,
+ )
except HTTPException:
raise
except Exception as e:
@@ -914,6 +966,7 @@ class CandidateView:
try:
if not user_id:
raise HTTPException(status_code=400,detail="user_id is required")
+ await assert_manager_candidate_access(self.session,current_user,user_id=user_id)
fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None}
if not fields:
raise HTTPException(status_code=400,detail="favorite or rating is required")
diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py
index beb75aa..086d24e 100644
--- a/backend/job/job_post/models.py
+++ b/backend/job/job_post/models.py
@@ -481,6 +481,21 @@ class JobPosts(SQLModel, table=True):
out.append(row_id)
return out
+ @classmethod
+ async def ids_for_creator(cls, session: AsyncSession, user_id):
+ """Job posts this user created. Recruiter Candidates scope (when they
+ lack candidates.manage) follows job_posts.created_by, not role_id."""
+ uid = cls._as_uuid(user_id)
+ if uid is None:
+ return []
+ result = await session.execute(
+ select(cls.id).where(
+ cls.created_by == uid,
+ cls.is_deleted == False, # noqa: E712
+ )
+ )
+ return list(result.scalars().all())
+
@classmethod
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
"""Open requisitions per hiring manager, keyed by users.id."""
diff --git a/backend/role/app.py b/backend/role/app.py
index e057734..14e4971 100644
--- a/backend/role/app.py
+++ b/backend/role/app.py
@@ -48,6 +48,11 @@ class RolePermissionTagsUpdate(BaseModel):
is_active: bool | None = None
+class RoleMatrixUpdate(BaseModel):
+ """Exact tag ids for one role. Saved onto that role's overlay bundle."""
+ permission_tags: list[int]
+
+
@router.get("/roles/fetch")
async def fetch_roles(
current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)),
@@ -174,6 +179,23 @@ async def update_permission(
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
+@router.put("/roles/matrix/update")
+async def update_role_matrix(
+ payload: RoleMatrixUpdate,
+ current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_EDIT)),
+ record_id: int = Query(...),
+ session: AsyncSession = Depends(get_session),
+):
+ try:
+ service=Role(session=session)
+ data=await service.set_role_matrix(record_id,payload.permission_tags)
+ return JSONResponse(content={"data":data,"status_code":200})
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500,detail=str(e))
+
+
@router.put("/roles/permission-tags/update")
async def update_role_permission_tags(
payload: RolePermissionTagsUpdate,
diff --git a/backend/role/views.py b/backend/role/views.py
index 9109768..453eec1 100644
--- a/backend/role/views.py
+++ b/backend/role/views.py
@@ -81,6 +81,46 @@ class Role:
updated = await Roles.update_role(self.session, int(record_id), fields)
return await self._role_payload(updated)
+ async def set_role_matrix(self, record_id, permission_tags):
+ """Write the Access Control grid onto one overlay bundle for this role.
+
+ Shared system bundles are not mutated. The role then points at that
+ overlay only, so a ticked cell is the grant and an unticked cell is not.
+ """
+ role = await Roles.get_role_by_id(self.session, int(record_id))
+ if not role or role.is_deleted:
+ raise HTTPException(status_code=404, detail="Role not found")
+ tag_ids = sorted({int(i) for i in (permission_tags or [])})
+ found = await PermissionTags.get_permission_tags_by_ids(self.session, tag_ids)
+ unknown = sorted(set(tag_ids) - {t.id for t in found})
+ if unknown:
+ raise HTTPException(
+ status_code=422,
+ detail=f"Unknown or inactive permission tag ids: {unknown}",
+ )
+ overlay_name = f"role_{role.id}_matrix"
+ bundle = await Permissions.get_permission_by_name(self.session, overlay_name)
+ if bundle is None:
+ bundle = await Permissions.insert_permission(
+ self.session,
+ {
+ "name": overlay_name,
+ "description": f"Access Control matrix for {role.role_name}",
+ "permission_tags": tag_ids,
+ "is_system": False,
+ "is_active": True,
+ "is_deleted": False,
+ },
+ )
+ else:
+ await Permissions.update_permission(
+ self.session, int(bundle.id), {"permission_tags": tag_ids},
+ )
+ updated = await Roles.update_role(
+ self.session, int(record_id), {"permissions": [int(bundle.id)]},
+ )
+ return await self._role_payload(updated)
+
async def delete_role(self, record_id):
role = await Roles.get_role_by_id(self.session, int(record_id))
if not role or role.is_deleted:
diff --git a/backend/users/permissions.py b/backend/users/permissions.py
index e420768..83e26bc 100644
--- a/backend/users/permissions.py
+++ b/backend/users/permissions.py
@@ -234,6 +234,19 @@ def is_admin(current_user: dict | None) -> bool:
return PermissionTag.REQUISITIONS_MANAGE.value in granted
+def sees_all_candidates(current_user: dict | None) -> bool:
+ """Unscoped Candidates list: admins, or any role granted candidates.manage.
+
+ Absence of that tag (with candidates.view) scopes the list to jobs the
+ user created. Do not key this off role_id — a custom role must be able
+ to opt in through Access Control.
+ """
+ if is_admin(current_user):
+ return True
+ granted = (current_user or {}).get("permissions") or []
+ return PermissionTag.CANDIDATES_MANAGE.value in granted
+
+
def has_permission(
granted: set[str] | list[str] | tuple[str, ...],
*required: PermissionTag,
diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js
index b4d8c96..4544e42 100644
--- a/frontend/src/api/candidates.js
+++ b/frontend/src/api/candidates.js
@@ -1,16 +1,4 @@
-/* ============================================================
- candidates.js — candidate endpoints (backend/job/app.py).
- Two data families share this module:
- - ATS scoring (persisted `candidates` table): listJobs, listCandidates,
- getCandidate, scoreUploads, scoreInbox, toCandidateView.
- - Candidate profiles (inbox -> users -> roles join): list, getByUserId,
- toRows.
-
- Same conventions as inbox.js: one named export per endpoint, no hooks,
- camelCase params mapped to snake_case at the call boundary, and every
- function returns the parsed {data, total, status_code} envelope.
- ============================================================ */
import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
import { toDate } from '../lib/format'
@@ -163,25 +151,7 @@ export function toCandidateView(row) {
}
}
-/**
- * Candidate USER accounts — `users` rows filtered by role, not the scored
- * `candidates` table. Needs candidates.view.
- *
- * role_id 8 is the seeded `candidate` role (id 4 is hiring_manager, the signup
- * default). We send it explicitly so a missing param cannot list the wrong people.
- *
- * This route:
- * - it returns `{data, status_code}` with NO `total` on the list; use
- * GET /candidate/fetch/users/count (once on page open) for the pager total;
- * - `top`/`skip` page the list; the Candidates screen sends the user's page
- * size as `top` and `(page-1)*top` as `skip`;
- * - `assigned_job_post_id` keeps only users assigned to that job post
- * (`inbox_messages.assigned_job_post_id`). Omit it for All Jobs.
- * - it accepts a `search` query param but never forwards it to the service
- * layer (`get_users(role_id=, top=, skip=)`), so searching is a no-op
- * server-side. Filtering stays client-side on the fetched page until that
- * is fixed.
- */
+
export function listCandidateUsers({ roleId = 8, top = 10, skip = 0, assignedJobPostId } = {}) {
return request('/candidate/fetch/users', {
params: { role_id: roleId, top, skip, assigned_job_post_id: assignedJobPostId },
@@ -195,14 +165,7 @@ export function countCandidateUsers({ roleId = 8, search, assignedJobPostId } =
})
}
-/**
- * `users` row -> the row shape the Candidates table renders.
- *
- * A user account carries identity only. Everything the ATS produces
- * (score, matched skills, critique, the job it was scored against) lives in the
- * `candidates` table keyed by job_id + content hash, with no user_id to join on, so
- * those fields are null here by construction rather than by omission.
- */
+
export function toCandidateUserView(row) {
return {
id: row.id,
@@ -279,48 +242,19 @@ export function toApplicationListView(row) {
}
}
-/**
- * Candidate profiles — the `inbox -> users -> roles` join, restricted server-side
- * to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile).
- *
- * Permissioned with require_permission(CANDIDATES_VIEW), so a caller without the
- * tag gets a 403.
- *
- * `search` is an ilike over users.name / users.email only — it does NOT reach
- * the résumé text or the suggested job titles.
- *
- * `assignedJobPostId` maps to `assigned_job_post_id` and keeps only people
- * assigned to that job. Empty / omitted is All Jobs.
- */
+
export function list({ search, limit, offset, assignedJobPostId } = {}) {
return request('/candidate/fetch', {
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId },
})
}
-/**
- * One candidate by users.id.
- *
- * Passing user_id switches the endpoint into DETAIL mode
- * (backend/job/candidate/views.py:get_candidate), which is a different and much
- * larger payload than the list rows: résumé text, the AI match verdict, phone,
- * education, source, documents, favorite/rating, and the four child collections
- * — interviews, activity, feedback, notes — flattened across every inbox row the
- * candidate owns.
- *
- * NOTE the asymmetric response: get_candidate_profile returns a BARE OBJECT
- * rather than a one-element list when user_id matches exactly one row
- * (backend/inbox/models.py:68-70). Callers must normalise — see toRows().
- */
+
export function getByUserId(userId) {
return request('/candidate/fetch', { params: { user_id: userId } })
}
-/**
- * Candidates allocated to jobs this hiring manager owns — requisitions they
- * created (or are assigned on) → linked job posts → applications.
- * Needs candidates.view. Server-scoped; recruiters should not use this.
- */
+
export function listForManager({ limit = 50, offset = 0 } = {}) {
return request('/candidate/manager/fetch', { params: { limit, offset } })
}
@@ -331,11 +265,7 @@ export function toRows(res) {
return res?.data ? [res.data] : []
}
-/**
- * Assigned job-post ids only. `job_posts` / suggested_job_post_ids are
- * matcher hints, not an assignment — the Candidates / Talent Pool job
- * filter must not treat a suggestion as a link.
- */
+
export function jobIdsOf(row) {
if (!row || typeof row !== 'object') return []
const ids = []
diff --git a/frontend/src/api/roles.js b/frontend/src/api/roles.js
index b95cb10..605d6f1 100644
--- a/frontend/src/api/roles.js
+++ b/frontend/src/api/roles.js
@@ -34,3 +34,12 @@ export function listPermissionTags() {
export function updatePermissionTags(body) {
return request('/roles/permission-tags/update', { method: 'PUT', body })
}
+
+/** Access Control grid save — PUT onto a per-role overlay bundle. */
+export function updateRoleMatrix(recordId, permissionTags) {
+ return request('/roles/matrix/update', {
+ method: 'PUT',
+ params: { record_id: recordId },
+ body: { permission_tags: permissionTags },
+ })
+}
diff --git a/frontend/src/auth/permissions.js b/frontend/src/auth/permissions.js
index 08afc26..095a912 100644
--- a/frontend/src/auth/permissions.js
+++ b/frontend/src/auth/permissions.js
@@ -48,3 +48,17 @@ export function isHiringManager(user) {
const name = (user?.role_name || '').trim().toLowerCase()
return name === HIRING_MANAGER_ROLE || name === 'manager'
}
+
+const ADMIN_ROLES = new Set(['system_administrator', 'hr_administrator', 'admin'])
+
+export function isAdmin(user) {
+ const name = (user?.role_name || '').trim().toLowerCase()
+ if (ADMIN_ROLES.has(name)) return true
+ return (user?.permissions || []).includes('requisitions.manage')
+}
+
+/** Unscoped Candidates list — matches backend sees_all_candidates. */
+export function seesAllCandidates(user) {
+ if (isAdmin(user)) return true
+ return (user?.permissions || []).includes('candidates.manage')
+}
diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx
index a914631..e44c395 100644
--- a/frontend/src/screens/Candidates.jsx
+++ b/frontend/src/screens/Candidates.jsx
@@ -3,9 +3,11 @@
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
per application, so score / stage / job / recruiter have a source.
- Hiring managers use GET /candidate/manager/fetch (jobs on their
- requisitions). Adding a candidate still goes through CV Import or the
- Add Candidate modal — both run the CV through persisted ATS scoring.
+ Without candidates.manage the server scopes that list to jobs the user
+ created (job_posts.created_by). Hiring managers use GET
+ /candidate/manager/fetch (jobs on their requisitions). Adding a candidate
+ still goes through CV Import or the Add Candidate modal — both run the CV
+ through persisted ATS scoring.
============================================================ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -18,7 +20,7 @@ import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
-import { isHiringManager } from '../auth/permissions'
+import { isHiringManager, seesAllCandidates } from '../auth/permissions'
import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys'
@@ -64,11 +66,12 @@ async function fetchCandidates({ limit = DEFAULT_PAGE_SIZE, offset = 0, search,
}
}
-async function fetchJobs() {
+async function fetchJobs({ createdBy } = {}) {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows
.filter((row) => row && row.id != null)
+ .filter((row) => !createdBy || String(row.created_by || '') === String(createdBy))
.map((row) => ({
id: String(row.id),
title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled',
@@ -291,7 +294,9 @@ function RecruiterCandidates() {
const qc = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
+ const { user } = useAuth()
const updateCandidates = useSeedMutation('candidates')
+ const unscoped = seesAllCandidates(user)
const [q, setQ] = useState('')
const [jobId, setJobId] = useState('')
@@ -325,7 +330,10 @@ function RecruiterCandidates() {
assignedJobPostId: jobId || undefined,
}),
})
- const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
+ const jobsQuery = useQuery({
+ queryKey: qk.jobPosts.list({ createdBy: unscoped ? 'all' : (user?.id ?? null) }),
+ queryFn: () => fetchJobs({ createdBy: unscoped ? undefined : user?.id }),
+ })
const candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [candidatesQuery.data])
const jobsById = useMemo(
() => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])),
@@ -619,7 +627,9 @@ function RecruiterCandidates() {
-
| · | } - const on = granted.has(tag) + const on = draft.has(tag) return (
- toggleTag(tag)}
>
|
)
})}
@@ -280,6 +340,17 @@ export default function Rbac() {