From c1e44356e9cefbe47a10514a0eeb1ce24b8840ec Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Thu, 13 Aug 2026 18:21:35 +0500 Subject: [PATCH] . --- backend/inbox/models.py | 2 + backend/job/app.py | 9 ++- backend/job/candidate/models.py | 61 +++++++++++++++++---- backend/job/pipeline/serializers.py | 1 + backend/job/pipeline/views.py | 85 ++++++++++++++++++++++------- frontend/dist/index.html | 2 +- frontend/src/api/pipeline.js | 78 +++++++++++++++++++------- frontend/src/screens/Pipeline.jsx | 33 +++++++---- 8 files changed, 203 insertions(+), 68 deletions(-) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index cee2d69..424eb61 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -84,6 +84,7 @@ class Inbox(SQLModel, table=True): Users.email, Inbox_Messages.candidate_phone_number.label("phone"), Inbox_Messages.assigned_job_post_id, + Inbox_Messages.application_status, JobPosts.title, AtsResults.id.label("ats_result_id"), AtsResults.overall_score, @@ -121,6 +122,7 @@ class Inbox(SQLModel, table=True): "user_id":str(row["user_id"]) if row["user_id"] else None, "name":row["name"], "email":row["email"], + "application_status":row["application_status"].value if row["application_status"] else None, "phone":row["phone"], "assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None, "title":row["title"], diff --git a/backend/job/app.py b/backend/job/app.py index 7d028ff..d08d004 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -90,7 +90,8 @@ class FeedbackUpdate(BaseModel): class StageChange(BaseModel): - inbox_id: int + inbox_id: int | None = None + manual_upload_id: UUID | None = None to_stage: str change_reason: str | None = None @@ -667,7 +668,8 @@ async def change_candidate_stage( try: service=Pipeline(session=session) data=await service.change_stage( - payload.inbox_id,payload.to_stage,current_user,change_reason=payload.change_reason, + payload.to_stage,current_user,inbox_id=payload.inbox_id, + manual_upload_id=payload.manual_upload_id,change_reason=payload.change_reason, ) return JSONResponse(content={"data":data,"total":1,"status_code":200}) except HTTPException: @@ -699,12 +701,13 @@ async def fetch_pipeline_candidates( async def fetch_pipeline_transitions( transition_id:str=Query(None), inbox_id:int=Query(None), + manual_upload_id:UUID=Query(None), current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)), session: AsyncSession = Depends(get_session), ): try: service=Pipeline(session=session) - data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id) + data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id,manual_upload_id=manual_upload_id) total=1 if isinstance(data,dict) else len(data) return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 2149947..7cd553b 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -42,6 +42,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): platform: str = Field(default="") created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") experience: str = Field(default="") + # Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Applied. status: str = Field(default="") # Free text, not a users FK: a referrer is often someone outside the system referral_by: str = Field(default="", sa_column_kwargs={"server_default": ""}) @@ -66,6 +67,10 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): Users.name, cls.candidate_phone, JobPosts.title, + cls.status, + cls.current_company, + cls.current_position, + cls.experience, cls.created_at, cls.updated_at, AtsResults.id.label("ats_result_id"), @@ -109,6 +114,10 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): "name":row["name"], "candidate_phone":row["candidate_phone"], "title":row["title"], + "application_status":row["status"] or None, + "current_company":row["current_company"] or None, + "current_position":row["current_position"] or None, + "experience":row["experience"] or 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, "ats_result":ats, @@ -162,7 +171,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): platform=(fields.get("platform") or "").strip(), created_by=cls._as_uuid(fields.get("created_by")), experience=(fields.get("experience") or "").strip(), - status=(fields.get("status") or "").strip(), + status=(fields.get("status") or "").strip() or "PENDING", referral_by=(fields.get("referral_by") or "").strip(), file_name=(fields.get("file_name") or "").strip(), file_path=(fields.get("file_path") or "").strip(), @@ -182,6 +191,14 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): ) return result.scalars().first() + @classmethod + async def get_by_id(cls, session: AsyncSession, record_id): + uid = cls._as_uuid(record_id) + if uid is None: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + class Candidates(SQLModel, table=True): @@ -590,16 +607,20 @@ class Feedback(SQLModel, table=True): class ApplicationStageTransitions(SQLModel, table=True): - """Temporal history of inbox_messages.application_status changes. + """Temporal history of application stage changes. - valid_from / valid_to make time-in-stage a subtraction rather than a window - function. NULL valid_to means the stage is still current. + Inbox moves write inbox_messages.application_status; manual-upload moves + write manual_upload_candidate.status. Exactly one of inbox_id / + manual_upload_candidate_id is set. NULL valid_to means the stage is current. """ __tablename__ = "application_stage_transitions" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - inbox_id: int = Field(index=True, foreign_key="inbox.id") + inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id") + manual_upload_candidate_id: uuid.UUID | None = Field( + default=None, index=True, foreign_key="manual_upload_candidate.id" + ) from_stage: str | None = Field(default=None) to_stage: str valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) @@ -634,12 +655,28 @@ class ApplicationStageTransitions(SQLModel, table=True): return list(result.scalars().all()) @classmethod - async def get_open_transition(cls, session: AsyncSession, inbox_id: int): + async def fetch_by_manual(cls, session: AsyncSession, record_id): + uid = cls._as_uuid(record_id) + if uid is None: + return [] result = await session.execute( - select(cls) - .where(cls.inbox_id == int(inbox_id), cls.valid_to.is_(None)) - .order_by(cls.valid_from.desc()) + select(cls).where(cls.manual_upload_candidate_id == uid).order_by(cls.valid_from.desc()) ) + return list(result.scalars().all()) + + @classmethod + async def get_open_transition(cls, session: AsyncSession, inbox_id=None, manual_upload_candidate_id=None): + statement = select(cls).where(cls.valid_to.is_(None)).order_by(cls.valid_from.desc()) + if inbox_id is not None: + statement = statement.where(cls.inbox_id == int(inbox_id)) + elif manual_upload_candidate_id is not None: + uid = cls._as_uuid(manual_upload_candidate_id) + if uid is None: + return None + statement = statement.where(cls.manual_upload_candidate_id == uid) + else: + return None + result = await session.execute(statement) return result.scalars().first() @classmethod @@ -652,8 +689,10 @@ class ApplicationStageTransitions(SQLModel, table=True): return row @classmethod - async def close_open(cls, session: AsyncSession, inbox_id: int, *, at: datetime | None = None, commit: bool = False): - row = await cls.get_open_transition(session, inbox_id) + async def close_open(cls, session: AsyncSession, inbox_id=None, *, manual_upload_candidate_id=None, at: datetime | None = None, commit: bool = False): + row = await cls.get_open_transition( + session, inbox_id=inbox_id, manual_upload_candidate_id=manual_upload_candidate_id + ) if not row: return None row.valid_to = at or _now() diff --git a/backend/job/pipeline/serializers.py b/backend/job/pipeline/serializers.py index 030bb84..4d94b49 100644 --- a/backend/job/pipeline/serializers.py +++ b/backend/job/pipeline/serializers.py @@ -2,6 +2,7 @@ def serialize_stage_transition(row) -> dict: return { "id": str(row.id), "inbox_id": row.inbox_id, + "manual_upload_candidate_id": str(row.manual_upload_candidate_id) if row.manual_upload_candidate_id else None, "from_stage": row.from_stage, "to_stage": row.to_stage, "valid_from": row.valid_from.isoformat() if row.valid_from else None, diff --git a/backend/job/pipeline/views.py b/backend/job/pipeline/views.py index c15c578..9f25f67 100644 --- a/backend/job/pipeline/views.py +++ b/backend/job/pipeline/views.py @@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from inbox.enums import Candidate_application_Status from inbox.models import Inbox -from job.candidate.models import ApplicationStageTransitions, Manual_UPLOAD_CANDIDATE +from job.candidate.models import ApplicationStageTransitions, Manual_UPLOAD_CANDIDATE, _now from job.pipeline.serializers import serialize_stage_transition from inbox.plugins import get_ats_score_for_manual_user, get_ats_score_for_user @@ -29,49 +29,57 @@ class Pipeline: except Exception as e: raise HTTPException(status_code=500,detail=str(e)) - async def get_transitions(self,inbox_id=None,transition_id=None): + async def get_transitions(self,inbox_id=None,transition_id=None,manual_upload_id=None): if transition_id: row=await ApplicationStageTransitions.get_by_id(self.session,transition_id) if not row: raise HTTPException(status_code=404,detail="Transition not found") - - return serialize_stage_transition(row) - if inbox_id is None: - raise HTTPException(status_code=400,detail="transition_id or inbox_id is required") - rows=await ApplicationStageTransitions.fetch_by_inbox(self.session,int(inbox_id)) - return [serialize_stage_transition(r) for r in rows] + if inbox_id is not None: + rows=await ApplicationStageTransitions.fetch_by_inbox(self.session,int(inbox_id)) + return [serialize_stage_transition(r) for r in rows] + if manual_upload_id is not None: + rows=await ApplicationStageTransitions.fetch_by_manual(self.session,manual_upload_id) + return [serialize_stage_transition(r) for r in rows] + raise HTTPException(status_code=400,detail="transition_id, inbox_id or manual_upload_id is required") - async def change_stage(self,inbox_id,to_stage,current_user,change_reason=None): + async def change_stage(self,to_stage,current_user,inbox_id=None,manual_upload_id=None,change_reason=None): + if (inbox_id is None)==(manual_upload_id is None): + raise HTTPException(status_code=400,detail="inbox_id or manual_upload_id is required") + try: + stage=Candidate_application_Status(to_stage) + except ValueError: + raise HTTPException(status_code=422,detail="Invalid to_stage") + changed_by=None + if isinstance(current_user,dict) and current_user.get("id"): + changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id")) + if inbox_id is not None: + return await self._change_inbox_stage(inbox_id,stage,changed_by,change_reason) + return await self._change_manual_stage(manual_upload_id,stage,changed_by,change_reason) + + async def _change_inbox_stage(self,inbox_id,stage,changed_by,change_reason): inbox=await Inbox.get_inbox_with_message(self.session,inbox_id) if not inbox: raise HTTPException(status_code=404,detail="Inbox not found") message=inbox.messages if not message: raise HTTPException(status_code=404,detail="Inbox message not found") - try: - stage=Candidate_application_Status(to_stage) - except ValueError: - raise HTTPException(status_code=422,detail="Invalid to_stage") current=message.application_status from_stage=current.value if isinstance(current,Candidate_application_Status) else str(current) if from_stage==stage.value: raise HTTPException(status_code=400,detail="already at stage") - changed_by=None - if isinstance(current_user,dict) and current_user.get("id"): - changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id")) await ApplicationStageTransitions.close_open(self.session,inbox.id,commit=False) - transition_data={ + transition=await ApplicationStageTransitions.insert_transition( + self.session, + { "inbox_id":inbox.id, + "manual_upload_candidate_id":None, "from_stage":from_stage, "to_stage":stage.value, "changed_by":changed_by, "actor_kind":"user", "change_reason":change_reason, - } - transition=await ApplicationStageTransitions.insert_transition( - self.session, - transition_data, + }, commit=False, ) message.application_status=stage @@ -79,6 +87,41 @@ class Pipeline: await self.session.commit() return { "inbox_id":inbox.id, + "manual_upload_id":None, + "application_status":stage.value, + "transition":serialize_stage_transition(transition), + } + + async def _change_manual_stage(self,manual_upload_id,stage,changed_by,change_reason): + row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,manual_upload_id) + if not row: + raise HTTPException(status_code=404,detail="Manual upload candidate not found") + from_stage=(row.status or "").strip() or None + if from_stage==stage.value: + raise HTTPException(status_code=400,detail="already at stage") + await ApplicationStageTransitions.close_open( + self.session,manual_upload_candidate_id=row.id,commit=False, + ) + transition=await ApplicationStageTransitions.insert_transition( + self.session, + { + "inbox_id":None, + "manual_upload_candidate_id":row.id, + "from_stage":from_stage, + "to_stage":stage.value, + "changed_by":changed_by, + "actor_kind":"user", + "change_reason":change_reason, + }, + commit=False, + ) + row.status=stage.value + row.updated_at=_now() + self.session.add(row) + await self.session.commit() + return { + "inbox_id":None, + "manual_upload_id":str(row.id), "application_status":stage.value, "transition":serialize_stage_transition(transition), } diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 5fe6fee..8df7bd6 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,7 +23,7 @@ - + diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js index c1c0dab..ea8dab1 100644 --- a/frontend/src/api/pipeline.js +++ b/frontend/src/api/pipeline.js @@ -1,17 +1,16 @@ /* ============================================================ pipeline.js — the kanban board's endpoints (backend/job/app.py). - The board is stitched from two modules, because there is no pipeline-specific - READ endpoint: + The board is stitched from two reads plus one write: - - rows come from GET /candidate/fetch (api/candidates.js `list`), the - inbox -> users -> roles join, which is the only list payload carrying BOTH - `application_status` (the stage) and `inbox_id` (what the write below needs); - - the write is PATCH /candidate/stage, here. + - inbox cards come from GET /candidate/fetch (api/candidates.js `list`); + - manual-upload cards come from GET /pipeline/candidates/fetch (`manual_upload`); + - the write is PATCH /candidate/stage, here. Inbox moves send `inbox_id`; + manual moves send `manual_upload_id`. Exactly one is required. - Stage lives on inbox_messages.application_status and the transition history in - application_stage_transitions; the server closes the open interval and opens a - new one in the same commit, so the board never has to touch history itself. + Inbox stage lives on inbox_messages.application_status; manual stage lives + on manual_upload_candidate.status (same Candidate_application_Status values). + History is application_stage_transitions in both cases. ============================================================ */ import { request } from '../lib/apiClient' @@ -60,31 +59,42 @@ export const STATUS_FROM_STAGE = { /** * Move one application to another stage. Requires pipeline.edit. * - * `inboxId` is the INTEGER inbox.id — the row the candidate profile returns as - * `inbox_id`, not the inbox_messages uuid the Inbox screen calls `id`; the route - * runs int() on it and 404s on anything else. - * - * The server rejects a no-op move with 400 ("already at stage"), so callers must - * not fire on a drop into the card's current column. + * Inbox cards send `inboxId` (INTEGER inbox.id). Manual-upload cards send + * `manualUploadId` (manual_upload_candidate.id UUID). The server 400s if both + * or neither are present, and 400s a no-op move ("already at stage"). */ -export function changeStage({ inboxId, toStage, changeReason }) { +export function changeStage({ inboxId, manualUploadId, toStage, changeReason }) { return request('/candidate/stage', { method: 'PATCH', - body: { inbox_id: inboxId, to_stage: toStage, change_reason: changeReason ?? null }, + body: { + ...(inboxId != null ? { inbox_id: inboxId } : {}), + ...(manualUploadId != null ? { manual_upload_id: manualUploadId } : {}), + to_stage: toStage, + change_reason: changeReason ?? null, + }, }) } +/** + * Inbox + manual-upload applications for the board — GET /pipeline/candidates/fetch + * (pipeline.view). Envelope `data` is `{ inbox, manual_upload }`. + */ +export function listApplications() { + return request('/pipeline/candidates/fetch') +} + /** * Stage history for one application — GET /pipeline/transitions/fetch * (pipeline.view). Rows are valid-time intervals: `valid_to` null is the stage * the candidate is in now. The board itself does not render history; this is the * feed behind a stage timeline on the profile. * - * One of inboxId / transitionId is required — the route 400s with neither. + * One of inboxId / manualUploadId / transitionId is required — the route 400s + * with none of them. */ -export function listTransitions({ inboxId, transitionId } = {}) { +export function listTransitions({ inboxId, manualUploadId, transitionId } = {}) { return request('/pipeline/transitions/fetch', { - params: { inbox_id: inboxId, transition_id: transitionId }, + params: { inbox_id: inboxId, manual_upload_id: manualUploadId, transition_id: transitionId }, }) } @@ -105,6 +115,7 @@ export function toBoardCard(row) { return { id: row.inbox_id, inboxId: row.inbox_id, + manualUploadId: null, userId: row.user_id ?? null, name: row.name || row.email || 'Unknown', email: row.email ?? null, @@ -120,3 +131,30 @@ export function toBoardCard(row) { applied: row.created_at ? new Date(row.created_at) : null, } } + +/** + * GET /pipeline/candidates/fetch `manual_upload` row -> one kanban card. + * + * `id` is prefixed so it cannot collide with an integer inbox id. Stage is + * `manual_upload_candidate.status`, exposed as `application_status`. + */ +export function toManualBoardCard(row) { + return { + id: `manual:${row.id}`, + inboxId: null, + manualUploadId: row.id, + userId: row.user_id ?? null, + name: row.name || row.email || 'Unknown', + email: row.email ?? null, + stage: STAGE_FROM_STATUS[row.application_status] ?? 'Applied', + status: row.application_status ?? null, + jobId: row.job_post_id ?? null, + jobTitle: row.title ?? null, + currentTitle: row.current_position || null, + currentCompany: row.current_company || null, + experience: row.experience || null, + aiScore: row.ats_result?.overall_score ?? null, + recommendation: row.ats_result?.band ?? null, + applied: row.created_at ? new Date(row.created_at) : null, + } +} diff --git a/frontend/src/screens/Pipeline.jsx b/frontend/src/screens/Pipeline.jsx index 9c75bb5..75d7b7c 100644 --- a/frontend/src/screens/Pipeline.jsx +++ b/frontend/src/screens/Pipeline.jsx @@ -1,15 +1,13 @@ /* ============================================================ Pipeline — the kanban board, on live backend data. - Cards come from GET /candidate/fetch (the inbox -> users -> roles join), the - only list payload that carries the stage (`application_status`) together with - the `inbox_id` that PATCH /candidate/stage writes against. Dropping a card - fires that PATCH; the server closes the open application_stage_transitions - interval and opens a new one in the same commit. + Inbox cards come from GET /candidate/fetch; manual-upload cards from + GET /pipeline/candidates/fetch. Dropping a card fires PATCH /candidate/stage + with `inbox_id` or `manual_upload_id`. The server closes the open + application_stage_transitions interval and opens a new one in the same commit. - The job filter reads live posts from GET /job/fetch and matches on - `assigned_job_post_id`, so an application nobody has assigned to a post shows - under All Jobs only. + The job filter reads live posts from GET /job/fetch and matches on job id, + so an application nobody has assigned to a post shows under All Jobs only. The card's skill tags are gone: the list payload has ai_score but no matched_keywords, and the Candidates screen set the precedent that a column @@ -49,9 +47,19 @@ const BOARD_KEY = qk.pipeline.board({ limit: BOARD_LIMIT }) const JOB_LIMIT = 100 async function fetchBoard() { - const res = await candidatesApi.list({ limit: BOARD_LIMIT }) + const [res, pipe] = await Promise.all([ + candidatesApi.list({ limit: BOARD_LIMIT }), + pipelineApi.listApplications(), + ]) const rows = candidatesApi.toRows(res) - return { cards: rows.map(pipelineApi.toBoardCard), total: res?.total ?? rows.length } + const manuals = Array.isArray(pipe?.data?.manual_upload) ? pipe.data.manual_upload : [] + return { + cards: [ + ...rows.map(pipelineApi.toBoardCard), + ...manuals.map(pipelineApi.toManualBoardCard), + ], + total: (res?.total ?? rows.length) + manuals.length, + } } async function fetchJobs() { @@ -106,6 +114,7 @@ export default function Pipeline() { mutationFn: ({ card, stage }) => pipelineApi.changeStage({ inboxId: card.inboxId, + manualUploadId: card.manualUploadId, toStage: pipelineApi.STATUS_FROM_STAGE[stage], }), onMutate: async ({ card, stage }) => { @@ -114,7 +123,7 @@ export default function Pipeline() { qc.setQueryData(BOARD_KEY, (old) => old && { ...old, - cards: old.cards.map((c) => (c.inboxId === card.inboxId ? { ...c, stage } : c)), + cards: old.cards.map((c) => (c.id === card.id ? { ...c, stage } : c)), }, ) return { previous } @@ -140,7 +149,7 @@ export default function Pipeline() { const cand = candidates.find((c) => c.id === id) // A no-op move is a 400 server-side ("already at stage"), so it never leaves. if (!cand || cand.stage === stage) return - if (cand.inboxId == null) { + if (cand.inboxId == null && cand.manualUploadId == null) { toast(`${cand.name} has no application to move`, 'warning') return }