Merge pull request 'SQS_BROKER' (#68) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 33s Details

Reviewed-on: #68
pull/69/head^2
ahmed.mujtaba 2026-09-04 11:12:47 +00:00
commit 6a1f5ba9fa
14 changed files with 321 additions and 122 deletions

View File

@ -214,8 +214,10 @@ class Inbox(SQLModel, table=True):
return or_(Users.name.ilike(pattern), Users.email.ilike(pattern)) return or_(Users.name.ilike(pattern), Users.email.ilike(pattern))
@classmethod @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: try:
if job_post_ids is not None and not list(job_post_ids):
return []
options=[selectinload(cls.messages)] options=[selectinload(cls.messages)]
if user_id: if user_id:
options.extend([ options.extend([
@ -234,6 +236,11 @@ class Inbox(SQLModel, table=True):
qry = qry.where(cls.user_id == user_id) qry = qry.where(cls.user_id == user_id)
if search: if search:
qry = qry.where(cls._candidate_search_filter(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 # 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. # boundary can't drop or repeat a row when created_at collides.
qry = qry.order_by(cls.created_at.desc(), cls.id.desc()) 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)) raise HTTPException(status_code=500,detail=str(e))
@classmethod @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.""" """Result-set size for the same predicate get_candidate_profile pages over."""
try: try:
if job_post_ids is not None and not list(job_post_ids):
return 0
qry = ( qry = (
select(func.count()) select(func.count())
.select_from(cls) .select_from(cls)
@ -261,6 +270,11 @@ class Inbox(SQLModel, table=True):
qry = qry.where(cls.user_id == user_id) qry = qry.where(cls.user_id == user_id)
if search: if search:
qry = qry.where(cls._candidate_search_filter(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) result = await session.execute(qry)
return result.scalar_one() return result.scalar_one()
except Exception as e: except Exception as e:

View File

@ -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}) return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException: except HTTPException:
raise raise

View File

@ -390,16 +390,20 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
return result.scalars().first() return result.scalars().first()
@classmethod @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).""" """Newest applications with a user + job for Talent Pool (manual / form)."""
from users.models import Users from users.models import Users
if job_post_ids is not None and not list(job_post_ids):
return []
statement = ( statement = (
select(cls) select(cls)
.join(Users, cls.user_id == Users.id) .join(Users, cls.user_id == Users.id)
.where(cls.user_id.is_not(None), cls.job_post_id.is_not(None)) .where(cls.user_id.is_not(None), cls.job_post_id.is_not(None))
.order_by(cls.created_at.desc()) .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: if search:
like = f"%{search.strip()}%" like = f"%{search.strip()}%"
statement = statement.where( statement = statement.where(

View File

@ -32,7 +32,7 @@ from job.history.views import HistoryRecorder
from job.notes.serializers import serialize_note from job.notes.serializers import serialize_note
from job.candidate.plugins import extract_candidate_email from job.candidate.plugins import extract_candidate_email
from users.models import Users 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 from employment_agent.plugins import parse_phone
load_dotenv() load_dotenv()
@ -42,6 +42,7 @@ MANUAL_UPLOAD_TO_ADDRESS=os.getenv(
"MANUAL_UPLOAD_TO_ADDRESS","manual-cv-upload@hr-ats.local" "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" 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): 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 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( async def assert_manager_candidate_access(
session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None, 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.""" """Row access: hiring-manager jobs, unscoped (manage/admin), or jobs this user created."""
if not is_hiring_manager(current_user): if not is_hiring_manager(current_user) and sees_all_candidates(current_user):
return 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: 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 job_id=JobPosts._as_uuid(job_post_id) if job_post_id is not None else None
uid=user_id uid=user_id
if job_id is None and (inbox_id is not None or manual_id is not None): 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) candidate_jobs=await assigned_job_ids_for_user(session,uid)
if candidate_jobs & owned: if candidate_jobs & owned:
return 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: 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: async def parse_linkedin_url_from_cv(resume_text) -> str | None:
@ -789,21 +823,32 @@ class CandidateView:
try: try:
if not user_id and is_hiring_manager(current_user): if not user_id and is_hiring_manager(current_user):
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) 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( await assert_manager_candidate_access(
self.session,current_user,user_id=user_id, self.session,current_user,user_id=user_id,
) )
detail=bool(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: if detail:
records=rows if isinstance(rows,list) else ([rows] if rows else []) records=rows if isinstance(rows,list) else ([rows] if rows else [])
if records and is_hiring_manager(current_user): owned=await owned_job_ids_for_candidate_scope(self.session,current_user)
owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id"))) if records and owned is not None:
owned_set=set(owned)
kept=[] kept=[]
for rec in records: for rec in records:
msg=getattr(rec,"messages",None) msg=getattr(rec,"messages",None)
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else 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) kept.append(rec)
if kept: if kept:
return await self.attach_profile_detail(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) manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
if not manual: if not manual:
return [] return []
if is_hiring_manager(current_user): if owned is not None:
owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id"))) owned_set=set(owned)
if not manual.job_post_id or manual.job_post_id not in owned: if not manual.job_post_id or manual.job_post_id not in owned_set:
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) raise HTTPException(status_code=403,detail=_scope_detail(current_user))
user=await Users.get_user_by_id(self.session,user_id) user=await Users.get_user_by_id(self.session,user_id)
job_post=None job_post=None
if manual.job_post_id: if manual.job_post_id:
@ -841,7 +886,7 @@ class CandidateView:
if not isinstance(inbox_payloads,list): if not isinstance(inbox_payloads,list):
inbox_payloads=[inbox_payloads] if inbox_payloads else [] inbox_payloads=[inbox_payloads] if inbox_payloads else []
manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool( 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")} seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
manual_payloads=[] manual_payloads=[]
@ -902,9 +947,16 @@ class CandidateView:
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(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: 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -914,6 +966,7 @@ class CandidateView:
try: try:
if not user_id: if not user_id:
raise HTTPException(status_code=400,detail="user_id is required") 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} fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None}
if not fields: if not fields:
raise HTTPException(status_code=400,detail="favorite or rating is required") raise HTTPException(status_code=400,detail="favorite or rating is required")

View File

@ -481,6 +481,21 @@ class JobPosts(SQLModel, table=True):
out.append(row_id) out.append(row_id)
return out 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 @classmethod
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids): async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
"""Open requisitions per hiring manager, keyed by users.id.""" """Open requisitions per hiring manager, keyed by users.id."""

View File

@ -48,6 +48,11 @@ class RolePermissionTagsUpdate(BaseModel):
is_active: bool | None = None 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") @router.get("/roles/fetch")
async def fetch_roles( async def fetch_roles(
current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)),
@ -174,6 +179,23 @@ async def update_permission(
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(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") @router.put("/roles/permission-tags/update")
async def update_role_permission_tags( async def update_role_permission_tags(
payload: RolePermissionTagsUpdate, payload: RolePermissionTagsUpdate,

View File

@ -81,6 +81,46 @@ class Role:
updated = await Roles.update_role(self.session, int(record_id), fields) updated = await Roles.update_role(self.session, int(record_id), fields)
return await self._role_payload(updated) 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): async def delete_role(self, record_id):
role = await Roles.get_role_by_id(self.session, int(record_id)) role = await Roles.get_role_by_id(self.session, int(record_id))
if not role or role.is_deleted: if not role or role.is_deleted:

View File

@ -234,6 +234,19 @@ def is_admin(current_user: dict | None) -> bool:
return PermissionTag.REQUISITIONS_MANAGE.value in granted 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( def has_permission(
granted: set[str] | list[str] | tuple[str, ...], granted: set[str] | list[str] | tuple[str, ...],
*required: PermissionTag, *required: PermissionTag,

View File

@ -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 { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
import { toDate } from '../lib/format' 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 } = {}) { export function listCandidateUsers({ roleId = 8, top = 10, skip = 0, assignedJobPostId } = {}) {
return request('/candidate/fetch/users', { return request('/candidate/fetch/users', {
params: { role_id: roleId, top, skip, assigned_job_post_id: assignedJobPostId }, 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) { export function toCandidateUserView(row) {
return { return {
id: row.id, 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 } = {}) { export function list({ search, limit, offset, assignedJobPostId } = {}) {
return request('/candidate/fetch', { return request('/candidate/fetch', {
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId }, 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) { export function getByUserId(userId) {
return request('/candidate/fetch', { params: { user_id: 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 } = {}) { export function listForManager({ limit = 50, offset = 0 } = {}) {
return request('/candidate/manager/fetch', { params: { limit, offset } }) return request('/candidate/manager/fetch', { params: { limit, offset } })
} }
@ -331,11 +265,7 @@ export function toRows(res) {
return res?.data ? [res.data] : [] 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) { export function jobIdsOf(row) {
if (!row || typeof row !== 'object') return [] if (!row || typeof row !== 'object') return []
const ids = [] const ids = []

View File

@ -34,3 +34,12 @@ export function listPermissionTags() {
export function updatePermissionTags(body) { export function updatePermissionTags(body) {
return request('/roles/permission-tags/update', { method: 'PUT', 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 },
})
}

View File

@ -48,3 +48,17 @@ export function isHiringManager(user) {
const name = (user?.role_name || '').trim().toLowerCase() const name = (user?.role_name || '').trim().toLowerCase()
return name === HIRING_MANAGER_ROLE || name === 'manager' 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')
}

View File

@ -3,9 +3,11 @@
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
per application, so score / stage / job / recruiter have a source. per application, so score / stage / job / recruiter have a source.
Hiring managers use GET /candidate/manager/fetch (jobs on their Without candidates.manage the server scopes that list to jobs the user
requisitions). Adding a candidate still goes through CV Import or the created (job_posts.created_by). Hiring managers use GET
Add Candidate modal both run the CV through persisted ATS scoring. /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' 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 { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions' import { isHiringManager, seesAllCandidates } from '../auth/permissions'
import CandidateProfile from './CandidateProfile' import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile' import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys' 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 res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : [] const rows = Array.isArray(res?.data) ? res.data : []
return rows return rows
.filter((row) => row && row.id != null) .filter((row) => row && row.id != null)
.filter((row) => !createdBy || String(row.created_by || '') === String(createdBy))
.map((row) => ({ .map((row) => ({
id: String(row.id), id: String(row.id),
title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled', title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled',
@ -291,7 +294,9 @@ function RecruiterCandidates() {
const qc = useQueryClient() const qc = useQueryClient()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { user } = useAuth()
const updateCandidates = useSeedMutation('candidates') const updateCandidates = useSeedMutation('candidates')
const unscoped = seesAllCandidates(user)
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [jobId, setJobId] = useState('') const [jobId, setJobId] = useState('')
@ -325,7 +330,10 @@ function RecruiterCandidates() {
assignedJobPostId: jobId || undefined, 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 candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [candidatesQuery.data])
const jobsById = useMemo( const jobsById = useMemo(
() => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])), () => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])),
@ -619,7 +627,9 @@ function RecruiterCandidates() {
<EmptyState title={candidates.length ? 'No matches' : 'No applications yet'}> <EmptyState title={candidates.length ? 'No matches' : 'No applications yet'}>
{candidates.length {candidates.length
? 'Try a different search, job, stage, or band filter.' ? 'Try a different search, job, stage, or band filter.'
: 'Import a CV or add a candidate to see score, stage, and recruiter on this table.'} : unscoped
? 'Import a CV or add a candidate to see score, stage, and recruiter on this table.'
: 'Candidates appear here when they are allocated to a job you created.'}
</EmptyState> </EmptyState>
</td> </td>
</tr> </tr>

View File

@ -7,13 +7,11 @@
now come from GET /permission-tags/fetch, so a 105th tag appears here without now come from GET /permission-tags/fetch, so a 105th tag appears here without
a frontend change, and a renamed module cannot silently shift every column. a frontend change, and a renamed module cannot silently shift every column.
THE SAVE BUTTON IS STILL NOT A PER-CELL TOGGLE, AND THAT IS DELIBERATE. The Tick a cell to grant or revoke that module.action tag, then Save. Save
backend grants access through BUNDLES `roles.permissions` is a list of writes the exact set onto a per-role overlay bundle (PUT /roles/matrix/update)
permission-bundle ids, and `effective_permissions` is the resolved union. An so shared system bundles are not mutated. The Edit modal still assigns
arbitrary per-tag set is not expressible through PUT /roles/update, so the named bundles; the next Save of this grid replaces the role's bundle list
matrix stays read-only and the editable thing is the bundle set, which is with that overlay.
what actually determines access. Editing bundles writes real permissions;
a per-cell grid would have to lie about what it saved.
Delete is soft server-side and refuses system roles (Role.delete_role), so Delete is soft server-side and refuses system roles (Role.delete_role), so
the button is hidden on those rather than offered and rejected. the button is hidden on those rather than offered and rejected.
@ -26,6 +24,7 @@ import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { useFormState } from '../components/AuthLayout' import { useFormState } from '../components/AuthLayout'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
@ -45,11 +44,14 @@ function humanise(slug) {
export default function Rbac() { export default function Rbac() {
const { toast } = useToast() const { toast } = useToast()
const { can } = useAuth()
const qc = useQueryClient() const qc = useQueryClient()
const [selectedId, setSelectedId] = useState(null) const [selectedId, setSelectedId] = useState(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
const [confirmDelete, setConfirmDelete] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null)
const [draft, setDraft] = useState(() => new Set())
const canEdit = can('rbac_users.edit')
const rolesQuery = useQuery({ const rolesQuery = useQuery({
queryKey: qk.roles.list(), queryKey: qk.roles.list(),
@ -95,6 +97,38 @@ export default function Rbac() {
const role = roles.find((r) => r.id === selectedId) ?? roles[0] const role = roles.find((r) => r.id === selectedId) ?? roles[0]
const granted = useMemo(() => new Set(role?.effective_permissions ?? []), [role]) const granted = useMemo(() => new Set(role?.effective_permissions ?? []), [role])
const grantKey = (role?.effective_permissions ?? []).slice().sort().join('\0')
useEffect(() => {
setDraft(new Set(role?.effective_permissions ?? []))
}, [role?.id, grantKey])
const tagIdByName = useMemo(() => {
const map = new Map()
for (const t of tags) {
const name = t.tag_name || (t.module && t.action ? `${t.module}.${t.action}` : null)
if (name && t.id != null) map.set(name, t.id)
}
return map
}, [tags])
const dirty = useMemo(() => {
if (draft.size !== granted.size) return true
for (const tag of draft) {
if (!granted.has(tag)) return true
}
return false
}, [draft, granted])
const toggleTag = (tag) => {
if (!canEdit) return
setDraft((prev) => {
const next = new Set(prev)
if (next.has(tag)) next.delete(tag)
else next.add(tag)
return next
})
}
const invalidate = () => qc.invalidateQueries({ queryKey: qk.roles.all() }) const invalidate = () => qc.invalidateQueries({ queryKey: qk.roles.all() })
@ -129,6 +163,27 @@ export default function Rbac() {
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the role.'), 'error'), onError: (err) => toast(friendlyAuthError(err, 'Could not delete the role.'), 'error'),
}) })
const saveMatrix = useMutation({
mutationFn: ({ id, permissionTags }) => rolesApi.updateRoleMatrix(id, permissionTags),
onSuccess: (res) => {
const next = res?.data?.effective_permissions
if (Array.isArray(next)) setDraft(new Set(next))
invalidate()
toast('Permissions saved', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not save permissions.'), 'error'),
})
const saveRoleMatrix = () => {
if (!role || !canEdit) return
const permissionTags = []
for (const name of draft) {
const id = tagIdByName.get(name)
if (id != null) permissionTags.push(id)
}
saveMatrix.mutate({ id: role.id, permissionTags })
}
const totalTags = tags.length const totalTags = tags.length
return ( return (
@ -226,12 +281,12 @@ export default function Rbac() {
<div className="card-body"> <div className="card-body">
<p className="text-muted text-sm" style={{ marginBottom: 12 }}> <p className="text-muted text-sm" style={{ marginBottom: 12 }}>
<Icon name="lock" /> These are the roles <b>resolved</b> permissions. Access is granted Tick a module action to grant it, then <b>Save</b>. Grants are stored on this
through bundles roles overlay bundle
{role.bundles?.length {role.bundles?.length
? ` ${role.bundles.map((b) => b.name ?? b).join(', ')}` ? ` (currently ${role.bundles.map((b) => b.name ?? b).join(', ')})`
: ' — none assigned yet'} : ''}
. Edit the bundle set to change what this role can do. . Shared system bundles are not rewritten.
</p> </p>
{tagsQuery.isPending && ( {tagsQuery.isPending && (
@ -245,6 +300,7 @@ export default function Rbac() {
</EmptyState> </EmptyState>
)} )}
{tagsQuery.isSuccess && modules.length > 0 && ( {tagsQuery.isSuccess && modules.length > 0 && (
<>
<div className="table-wrap"> <div className="table-wrap">
<table className="rbac-matrix"> <table className="rbac-matrix">
<thead> <thead>
@ -262,16 +318,20 @@ export default function Rbac() {
if (!tagSet.has(tag)) { if (!tagSet.has(tag)) {
return <td key={action}><span className="text-muted">·</span></td> return <td key={action}><span className="text-muted">·</span></td>
} }
const on = granted.has(tag) const on = draft.has(tag)
return ( return (
<td key={action}> <td key={action}>
<span <button
type="button"
className={`perm-check${on ? ' on' : ''}`} className={`perm-check${on ? ' on' : ''}`}
title={tag} title={tag}
disabled={!canEdit || saveMatrix.isPending}
aria-pressed={on}
aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`} aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`}
onClick={() => toggleTag(tag)}
> >
<Icon name="check" /> <Icon name="check" />
</span> </button>
</td> </td>
) )
})} })}
@ -280,6 +340,17 @@ export default function Rbac() {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="rbac-save">
<button
type="button"
className="btn btn-primary"
disabled={!canEdit || !dirty || saveMatrix.isPending}
onClick={saveRoleMatrix}
>
<Icon name="check" /> {saveMatrix.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</>
)} )}
</div> </div>
</> </>

View File

@ -1320,10 +1320,12 @@ canvas { width: 100%; max-width: 100%; display: block; }
.rbac-matrix th:first-child { text-align: left; padding-left: 16px; } .rbac-matrix th:first-child { text-align: left; padding-left: 16px; }
.rbac-matrix td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: center; } .rbac-matrix td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: center; }
.rbac-matrix td:first-child { text-align: left; padding-left: 16px; font-weight: 600; } .rbac-matrix td:first-child { text-align: left; padding-left: 16px; font-weight: 600; }
.perm-check { width: 22px; height: 22px; border-radius: 6px; border: 2px solid var(--border-strong); display: inline-grid; place-items: center; cursor: pointer; transition: .12s; } .perm-check { width: 22px; height: 22px; border-radius: 6px; border: 2px solid var(--border-strong); display: inline-grid; place-items: center; cursor: pointer; transition: .12s; background: transparent; padding: 0; font: inherit; color: inherit; }
.perm-check.on { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); } .perm-check.on { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
.perm-check.on svg { width: 13px; height: 13px; } .perm-check.on svg { width: 13px; height: 13px; }
.perm-check:not(.on) svg { display: none; } .perm-check:not(.on) svg { display: none; }
.perm-check:disabled { cursor: not-allowed; opacity: 0.55; }
.rbac-save { display: flex; justify-content: flex-end; margin-top: 16px; }
/* AI Assistant chat */ /* AI Assistant chat */
.chat-wrap { display: flex; flex-direction: column; height: calc(100vh - 190px); height: calc(100dvh - 190px); } .chat-wrap { display: flex; flex-direction: column; height: calc(100vh - 190px); height: calc(100dvh - 190px); }