pull/15/head
ahmed.mujtaba 2026-08-13 18:21:35 +05:00
parent 0899342c34
commit c1e44356e9
8 changed files with 203 additions and 68 deletions

View File

@ -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"],

View File

@ -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:

View File

@ -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()

View File

@ -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,

View File

@ -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),
}

View File

@ -23,7 +23,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-CnIht1Bn.js"></script>
<script type="module" crossorigin src="/assets/index-CdtlKBsf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
</head>
<body>

View File

@ -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,
}
}

View File

@ -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
}