From 1fa25f28548447976ec46f983ab6ecae6bca4b5e Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Tue, 15 Sep 2026 14:13:58 +0500 Subject: [PATCH] , --- backend/inbox/models.py | 8 +++- backend/job/app.py | 6 ++- backend/job/assignment/views.py | 2 +- backend/job/candidate/models.py | 4 +- backend/job/job_post/models.py | 70 +++++++++++++---------------- backend/job/job_post/serializers.py | 36 +++++++-------- backend/job/job_post/views.py | 45 ++++++++----------- backend/notifications/views.py | 3 -- backend/users/models.py | 31 ++++++++++--- frontend/src/api/jobs.js | 37 ++++++++++++++- 10 files changed, 138 insertions(+), 104 deletions(-) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 2559f2e..107cc93 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -1359,7 +1359,7 @@ class Inbox_Messages(SQLModel, table=True): return row @classmethod - async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids) -> dict[str, int]: + async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids,search=None,top=None,limit=None) -> dict[str, int]: """Resolve {job_post_id: applicant_count} for a page of rows in a single query. Counts Inbox_Messages rows, not Inbox rows: one message fans out to several @@ -1373,6 +1373,12 @@ class Inbox_Messages(SQLModel, table=True): .where(cls.assigned_job_post_id.in_(uids)) .group_by(cls.assigned_job_post_id) ) + if search: + result = result.where(cls.message_from.ilike(f"%{search}%")) + if top: + result = result.limit(top) + if limit: + result = result.limit(limit) return {str(job_id): int(n) for job_id, n in result.all()} @classmethod diff --git a/backend/job/app.py b/backend/job/app.py index e1335a1..064f76e 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -153,7 +153,6 @@ class JobUpdate(BaseModel): experience_min: int | None = None experience_max: int | None = None description: str | None = None - current_recruiter_id: UUID | None = None current_recruiter_ids: list[UUID] | None = None hiring_manager_id: UUID | None = None requisition_id: UUID | None = None @@ -987,6 +986,9 @@ async def fetch_jobs( @router.get("/jobs/profile/fetch") async def fetch_job_profile( job_post_id: str = Query(...), + search: str | None = Query(None), + top: int | None = Query(None, ge=1), + limit: int | None = Query(None, ge=1), current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), session: AsyncSession = Depends(get_session), ): @@ -994,7 +996,7 @@ async def fetch_job_profile( per person, best score first) and the Suggested / Top Match header stats.""" try: service=JobPost(session=session) - data=await service.fetch_job_profile(job_post_id,current_user=current_user) + data=await service.fetch_job_profile(job_post_id,current_user=current_user,search=search,top=top,limit=limit) return JSONResponse(content={"data":data,"total":1,"status_code":200}) except HTTPException: raise diff --git a/backend/job/assignment/views.py b/backend/job/assignment/views.py index 454d29a..3676ee3 100644 --- a/backend/job/assignment/views.py +++ b/backend/job/assignment/views.py @@ -15,7 +15,7 @@ JOB_ASSIGNMENT_ROLES = { "hiring_manager": EnumRoles.HIRING_MANAGER, } JOB_OWNER_COLUMN = { - "primary_recruiter": "current_recruiter_id", + "primary_recruiter": "current_recruiter_ids", "hiring_manager": "hiring_manager_id", } diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 48ad300..1c1943a 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -1301,9 +1301,7 @@ class Interviews(SQLModel, table=True): interview_type: str = Field(default="") interview_status: str = Field(default="") inbox_id: int | None = Field(default=None, foreign_key="inbox.id") - # Optional denorm so Recruiter Hub can join interviews → job_posts.current_recruiter_id - # without walking inbox. Filled on create from the application's assigned job; - # migration 011 added the columns. user_id is the candidate, not the recruiter. + user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id") graph_event_id: str | None = Field(default=None) diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 0800ac4..36ff9f9 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -60,11 +60,7 @@ class JobPosts(SQLModel, table=True): department: str = Field(default="", sa_column_kwargs={"server_default": ""}) vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"}) closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) - # Who is working the req now (swappable). History lives in job_assignments - # with assignment_role=primary_recruiter; this column is the first / primary - # pointer so existing joins keep working. current_recruiter_ids is the full - # list (UUID strings) so more than one recruiter can sit on the same job. - current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") + current_recruiter_ids: list[str] = Field( default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"}, ) @@ -92,35 +88,35 @@ class JobPosts(SQLModel, table=True): except (TypeError, ValueError): return None - @staticmethod - def recruiter_ids_of(row) -> list[str]: - """UUID strings currently assigned as recruiters on a job row or mapping. + # @staticmethod + # def recruiter_ids_of(row) -> list[str]: + # """UUID strings currently assigned as recruiters on a job row or mapping. - Prefers current_recruiter_ids; falls back to current_recruiter_id so a - row that has not been backfilled still maps to one person. - """ - if isinstance(row, dict): - raw = row.get("current_recruiter_ids") - fallback = row.get("current_recruiter_id") - else: - raw = getattr(row, "current_recruiter_ids", None) - fallback = getattr(row, "current_recruiter_id", None) - out: list[str] = [] - seen: set[str] = set() - for item in raw or []: - uid = JobPosts._as_uuid(item) - if uid is None: - continue - key = str(uid) - if key in seen: - continue - seen.add(key) - out.append(key) - if not out: - uid = JobPosts._as_uuid(fallback) - if uid is not None: - out.append(str(uid)) - return out + # Prefers current_recruiter_ids; falls back to current_recruiter_id so a + # row that has not been backfilled still maps to one person. + # """ + # if isinstance(row, dict): + # raw = row.get("current_recruiter_ids") + # fallback = row.get("current_recruiter_id") + # else: + # raw = getattr(row, "current_recruiter_ids", None) + # fallback = getattr(row, "current_recruiter_id", None) + # out: list[str] = [] + # seen: set[str] = set() + # for item in raw or []: + # uid = JobPosts._as_uuid(item) + # if uid is None: + # continue + # key = str(uid) + # if key in seen: + # continue + # seen.add(key) + # out.append(key) + # if not out: + # uid = JobPosts._as_uuid(fallback) + # if uid is not None: + # out.append(str(uid)) + # return out @classmethod def has_recruiter(cls, recruiter_id): @@ -129,7 +125,6 @@ class JobPosts(SQLModel, table=True): if uid is None: return false() return or_( - cls.current_recruiter_id == uid, cls.current_recruiter_ids.contains([str(uid)]), ) @@ -137,7 +132,7 @@ class JobPosts(SQLModel, table=True): def no_recruiters(cls): """SQL: neither the pointer nor the JSON list names anyone.""" return and_( - cls.current_recruiter_id.is_(None), + cls.current_recruiter_ids.is_(None), func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0, ) @@ -146,7 +141,7 @@ class JobPosts(SQLModel, table=True): uid = cls._as_uuid(record_id) if uid is None: return None - result = await session.execute(select(cls).where(cls.id == uid)) + result = await session.execute(select(cls).where(cls.id == uid).order_by(cls.created_at.desc(),cls.id.desc())) return result.scalars().first() @classmethod @@ -517,7 +512,6 @@ class JobPosts(SQLModel, table=True): cls.department, cls.location, cls.requisition_status, - cls.current_recruiter_id, cls.current_recruiter_ids, cls.created_at, Recruiter.name.label("recruiter_name"), @@ -536,7 +530,7 @@ class JobPosts(SQLModel, table=True): .select_from(cls) .outerjoin(stats, stats.c.job_post_id == cls.id) .outerjoin(reapplied_stats, reapplied_stats.c.job_post_id == cls.id) - .outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id) + .outerjoin(Recruiter, Recruiter.id.in_(cls.current_recruiter_ids)) .where(cls.is_deleted == False) # noqa: E712 ) if active_only: diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 93c70b4..e49a18a 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -20,19 +20,17 @@ def serialize_job_post_title(row) -> dict: } -def _recruiter_payload(row, names=None): - """List of recruiter ids plus mapped names; first id stays the legacy pointer.""" - names = names or {} - ids = JobPosts.recruiter_ids_of(row) - mapped = [names.get(i) for i in ids] - first = ids[0] if ids else None - return { - "current_recruiter_id": first, - "current_recruiter_ids": ids, - "recruiter_name": next((n for n in mapped if n), None), - "recruiter_names": [n for n in mapped if n], - "recruiters": [{"id": i, "name": names.get(i)} for i in ids], - } +# def _recruiter_payload(row, names=None): +# """List of recruiter ids plus mapped names; first id stays the legacy pointer.""" +# names = names or [] +# # ids = JobPosts.recruiter_ids_of(row) +# mapped = [names.get(i) for i in ids] +# return { +# "current_recruiter_ids": ids, +# "recruiter_name": next((n for n in mapped if n), None), +# "recruiter_names": [n for n in mapped if n], +# "recruiters": [{"id": i, "name": names.get(i)} for i in ids], +# } def serialize_job_post(row, *, names=None) -> dict: @@ -77,9 +75,8 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na talent-pool filters key off it on attached job_posts. """ req = getattr(row, "requisition", None) - payload = _recruiter_payload(row, names) - if recruiter_name and not payload["recruiter_name"]: - payload["recruiter_name"] = recruiter_name + recruiter_names=names.get("recruiters") + hiring_manager_name=names.get("hiring_manager",None) return { "id": str(row.id), "title": row.title, @@ -89,8 +86,6 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na "employment_type": row.employment_type, "vacancies": row.vacancies, "platform": row.platform or None, - # Two different lifecycles, never conflate: requisition_status is hiring - # (open/closed/on_hold), status is Buffer publishing (draft/scheduled/...). "requisition_status": row.requisition_status, "status": row.status, "experience_min": row.experience_min, @@ -101,9 +96,6 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na "description": row.description, "is_active": row.is_active, "closed_at": row.closed_at.isoformat() if row.closed_at else None, - **payload, - "hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None, - "hiring_manager_name": hiring_manager_name, "requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None, "requisition_title": req.position_title if req else None, "requisition_department": req.department if req else None, @@ -112,6 +104,8 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na "created_by_name": row.user.name if getattr(row, "user", None) else None, "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "recruiters": recruiter_names, + "hiring_manager": hiring_manager_name, } diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index d43c50a..6b49504 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -1,3 +1,6 @@ +from typing import Any + + from datetime import date, time import logging import os @@ -45,28 +48,14 @@ MAX_JOB_IMAGE_BYTES=5*1024*1024 def _payload_recruiter_ids(payload): - """Prefer current_recruiter_ids; fall back to current_recruiter_id. None = omitted.""" - has_list="current_recruiter_ids" in payload and payload.get("current_recruiter_ids") is not None - has_one="current_recruiter_id" in payload - if has_list: - raw=payload.get("current_recruiter_ids") or [] - if not isinstance(raw,(list,tuple)): - raw=[raw] - ids=list(raw) - if not ids and has_one and payload.get("current_recruiter_id") not in (None,""): - ids=[payload.get("current_recruiter_id")] - return ids - if has_one: - raw=payload.get("current_recruiter_id") - return [] if raw in (None,"") else [raw] - return None - + if payload.get("current_recruiter_ids"): + raw=payload.get("current_recruiter_ids") + return raw def _recruiter_fields(users): ids=[str(u.id) for u in users] return { "current_recruiter_ids": ids, - "current_recruiter_id": users[0].id if users else None, } @@ -97,7 +86,6 @@ class JobPostCreate(BaseModel): scheduler_date: date | None = None due_at: str | None = None hiring_manager_id: UUID | None = None - current_recruiter_id: UUID | None = None current_recruiter_ids: list[UUID] | None = None requisition_id: UUID | None = None @@ -423,7 +411,7 @@ class JobPost: for r in rows ],total - async def fetch_job_profile(self,job_post_id,current_user=None): + async def fetch_job_profile(self,job_post_id,current_user=None,search=None,top=None,limit=None): """Job profile page: the requisition row, its suggested candidates and the Suggested / Top Match header stats — one round trip. @@ -431,24 +419,29 @@ class JobPost: and keywords come from that score's candidates row; a user- or form-identity score has none, so it borrows the newest completed row for the same email.""" uid=JobPosts._as_uuid(job_post_id) + if uid is None: raise HTTPException(status_code=422,detail="job_post_id must be a UUID") + + # it's a system admin job profile page, so we don't need to restrict the ids restrict=await self._restrict_ids_for_requisition_scope(current_user) + if restrict is not None and str(uid) not in {str(i) for i in restrict}: raise HTTPException(status_code=404,detail="Job post not found") + job=await JobPosts.get_job_post_by_id(self.session,uid) if not job or job.is_deleted: raise HTTPException(status_code=404,detail="Job post not found") - names=await Users.names_by_ids( - self.session, - JobPosts.recruiter_ids_of(job)+[job.hiring_manager_id], - ) - counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[job.id]) + recruiter_lst=job.current_recruiter_ids or [] + + recruiter_x_manager_names=await Users.names_by_ids(self.session,recruiter_lst,job.hiring_manager_id,search,top,limit) + + counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[job.id],search,top,limit) job_payload=serialize_job_row( job, - names=names, - hiring_manager_name=names.get(str(job.hiring_manager_id)), + names=recruiter_x_manager_names["recruiters"], + hiring_manager_name=recruiter_x_manager_names["hiring_manager"], applicant_count=counts.get(str(job.id),0), ) diff --git a/backend/notifications/views.py b/backend/notifications/views.py index 0680586..559f7e3 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -216,9 +216,6 @@ async def job_recruiter_ids(session, job): ids = set() if job is None: return ids - uid = _as_uuid(getattr(job, "current_recruiter_id", None)) - if uid is not None: - ids.add(uid) for raw in getattr(job, "current_recruiter_ids", None) or []: extra = _as_uuid(raw) if extra is not None: diff --git a/backend/users/models.py b/backend/users/models.py index 2dc47ce..37521c5 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -154,20 +154,37 @@ class Users(SQLModel, table=True): return [row[0] for row in result.all()] @classmethod - async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]: - """Resolve {user_id: name} in a single query. + async def names_by_ids(cls, session: AsyncSession, user_ids,manager_id=None,search=None,top=None,limit=None) -> dict[str, str]: - COLUMN select, not the Users entity: `select(cls)` would pull the five - selectin relations (role, job_posts, inbox, feedback, notes) for a - two-column lookup. - """ + data = {"recruiters": {}, "hiring_manager": {}} uids = {u for u in (user_ids or []) if u} if not uids: return {} result = await session.execute( select(cls.id, cls.name).where(cls.id.in_(uids)) ) - return {str(uid): name for uid, name in result.all()} + if search: + result = result.where(cls.name.ilike(f"%{search}%")) + if top: + result = result.limit(top) + if limit: + result = result.limit(limit) + + recruiter_result=result.all() + data["recruiters"] = {str(uid): name for uid, name in recruiter_result} + if manager_id: + result=await session.execute( + select(cls.id, cls.name).where(cls.id==manager_id,cls.role_id==4,cls.is_deleted==False) + ) + if search: + result = result.where(cls.name.ilike(f"%{search}%")) + if top: + result = result.limit(top) + if limit: + result = result.limit(limit) + manager_result=result.all() + data["hiring_manager"] = {str(uid): name for uid, name in manager_result} + return data @classmethod async def get_by_ids(cls, session: AsyncSession, ids): diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index afc4a89..310bfb1 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -64,7 +64,24 @@ function experienceLabel(min, max) { return `${min ?? max}+ years` } +/* Job ownership arrives in one of two shapes. serialize_job_row (GET /jobs/fetch, + GET /jobs/profile/fetch) sends the two roles as separate {id: name} objects: + + "recruiters": {"ed9e…": "Nida Khan"}, "hiring_manager": {"77aa…": "Amara Osei"} + + serialize_job_post — the inbox / matching / picker payload — still sends the + older flat keys (current_recruiter_ids, recruiter_names, recruiters as an + ARRAY of {id, name}, hiring_manager_id / hiring_manager_name). Both are read + here so one mapper serves every caller; the Array check is what tells the two + `recruiters` shapes apart. */ +function recruiterMap(row) { + const value = row?.recruiters + return value && typeof value === 'object' && !Array.isArray(value) ? value : null +} + function recruiterIdsFrom(row) { + const map = recruiterMap(row) + if (map) return Object.keys(map).map(String) const ids = Array.isArray(row?.current_recruiter_ids) ? row.current_recruiter_ids.filter(Boolean).map(String) : [] @@ -73,6 +90,8 @@ function recruiterIdsFrom(row) { } function recruiterNamesFrom(row) { + const map = recruiterMap(row) + if (map) return Object.values(map).filter(Boolean) if (Array.isArray(row?.recruiter_names) && row.recruiter_names.length) { return row.recruiter_names.filter(Boolean) } @@ -82,10 +101,24 @@ function recruiterNamesFrom(row) { return row?.recruiter_name ? [row.recruiter_name] : [] } +/** {id, name} of the hiring manager, from either payload shape. */ +function hiringManagerFrom(row) { + const value = row?.hiring_manager + if (value && typeof value === 'object') { + const [id, name] = Object.entries(value)[0] ?? [] + if (id) return { id: String(id), name: name ?? null } + } + return { + id: row?.hiring_manager_id ? String(row.hiring_manager_id) : null, + name: row?.hiring_manager_name ?? null, + } +} + /** API row -> what the Jobs table and detail modal render. */ export function toJobView(row) { const recruiterIds = recruiterIdsFrom(row) const recruiterNames = recruiterNamesFrom(row) + const manager = hiringManagerFrom(row) return { id: row.id, title: row.title, @@ -101,8 +134,8 @@ export function toJobView(row) { recruiterId: recruiterIds[0] || null, recruiterIds, recruiterNames, - hiringManager: row.hiring_manager_name, - hiringManagerId: row.hiring_manager_id, + hiringManager: manager.name, + hiringManagerId: manager.id, createdByName: row.created_by_name, applicantCount: row.applicant_count ?? 0, // A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.