diff --git a/backend/.env.example b/backend/.env.example index d1c83cf..93c9a77 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -25,6 +25,11 @@ CALENDAR_API_TOKEN= EMAIL_SYNC_FOLDER=inbox EMAIL_SYNC_SINCE= EMAIL_SYNC_CRON=* * * * * +# Daily Sync Inbox (POST /email/sync) at 05:00 AM PKT. Token must match the +# Bearer the cron sends; leave blank to skip the tick with a warning. +INBOX_SYNC_CRON=0 5 * * * +INBOX_SYNC_CRON_TZ=Asia/Karachi +CRON_INBOX_SYNC_TOKEN= JWT_SECRET_KEY= JWT_ALGORITHM=HS256 diff --git a/backend/cron_schdule/plugins.py b/backend/cron_schdule/plugins.py new file mode 100644 index 0000000..6f61baf --- /dev/null +++ b/backend/cron_schdule/plugins.py @@ -0,0 +1,54 @@ +"""HTTP client for scheduled system jobs — call the portal API, do not import views. + +The daily inbox sync goes through POST /email/sync so enqueue, coalescing, and +the mailbox_sync worker stay on one code path with the UI Sync Inbox button. +""" + +from __future__ import annotations + +import os + +import httpx +from dotenv import load_dotenv + +load_dotenv() + +INBOX_SYNC_PATH="/email/sync" +INBOX_SYNC_TIMEOUT=float(os.getenv("INBOX_SYNC_TIMEOUT_SECONDS","30")) + + +def _backend_url() -> str: + return (os.getenv("BACKEND_URL") or "http://localhost:8000").rstrip("/") + + +def _cron_token() -> str: + return (os.getenv("CRON_INBOX_SYNC_TOKEN") or "").strip() + + +async def call_inbox_sync_api(*, top=100, skip=0, test_on=True) -> dict: + """POST /email/sync on this service -> the JSON envelope. + + Uses CRON_INBOX_SYNC_TOKEN as Bearer. The route accepts that shared secret + in place of a recruiter JWT so the 05:00 PKT scheduler can enqueue a run. + """ + token=_cron_token() + if not token: + raise RuntimeError("CRON_INBOX_SYNC_TOKEN is not set") + params={ + "top":int(top if top is not None else 100), + "skip":int(skip if skip is not None else 0), + "test_on":bool(test_on) if test_on is not None else True, + } + async with httpx.AsyncClient(timeout=INBOX_SYNC_TIMEOUT) as client: + response=await client.post( + f"{_backend_url()}{INBOX_SYNC_PATH}", + params=params, + headers={"Authorization":f"Bearer {token}"}, + ) + if response.status_code>=400: + raise httpx.HTTPStatusError( + response.text, + request=response.request, + response=response, + ) + return response.json() diff --git a/backend/cron_schdule/tasks.py b/backend/cron_schdule/tasks.py new file mode 100644 index 0000000..2a58e53 --- /dev/null +++ b/backend/cron_schdule/tasks.py @@ -0,0 +1,53 @@ +"""Daily Sync Inbox cron — 05:00 AM PKT via httpx POST /email/sync. + +Worker: taskiq worker taskiq_management.broker_setup:broker cron_schdule.tasks +Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler cron_schdule.tasks +""" + +from __future__ import annotations + +import logging +import os + +import httpx +from dotenv import load_dotenv + +from cron_schdule.plugins import call_inbox_sync_api +from taskiq_management.broker_setup import broker + +load_dotenv() + +logger=logging.getLogger("cron_schdule.inbox_sync") + +# 05:00 Asia/Karachi (PKT, UTC+5, no DST). Override the expression or zone in .env. +INBOX_SYNC_CRON=os.getenv("INBOX_SYNC_CRON","0 5 * * *") +INBOX_SYNC_CRON_TZ=os.getenv("INBOX_SYNC_CRON_TZ","Asia/Karachi") +INBOX_SYNC_TOP=int(os.getenv("INBOX_SYNC_TOP","100")) +INBOX_SYNC_SKIP=int(os.getenv("INBOX_SYNC_SKIP","0")) +INBOX_SYNC_TEST_ON=os.getenv("INBOX_SYNC_TEST_ON","true").strip().lower() not in ("0","false","no") + + +@broker.task( + task_name="cron_schdule.sync_inbox", + schedule=[{"cron":INBOX_SYNC_CRON,"cron_offset":INBOX_SYNC_CRON_TZ}], +) +async def sync_inbox_daily() -> dict: + """Enqueue Outlook mailbox sync the same way the Inbox UI button does.""" + try: + payload=await call_inbox_sync_api( + top=INBOX_SYNC_TOP, + skip=INBOX_SYNC_SKIP, + test_on=INBOX_SYNC_TEST_ON, + ) + except RuntimeError as e: + logger.warning("daily inbox sync skipped: %s",e) + return {"error":"not_configured","detail":str(e)} + except httpx.ConnectError as e: + logger.warning("daily inbox sync unreachable: %s",e) + return {"error":"unreachable","detail":str(e)} + except httpx.HTTPStatusError as e: + status=e.response.status_code if e.response is not None else None + logger.warning("daily inbox sync HTTP %s",status) + return {"error":"http_error","status_code":status} + data=payload.get("data") if isinstance(payload,dict) else None + return {"status":"ok","data":data} diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 765c22e..2052cf0 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -1,16 +1,49 @@ +import hmac +import os +from typing import Annotated + from fastapi import APIRouter,Depends, Query from fastapi.responses import JSONResponse from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel from db_setup import get_session from inbox.enums import Candidate_application_Status from sqlalchemy.ext.asyncio import AsyncSession from inbox.views import Email -from users.permissions import PermissionTag, require_permission +from users.permissions import PermissionTag, get_current_user, require_permission from dotenv import load_dotenv load_dotenv() router = APIRouter() +_optional_bearer=HTTPBearer(auto_error=False) + + +def _cron_inbox_sync_token_ok(provided: str) -> bool: + expected=(os.getenv("CRON_INBOX_SYNC_TOKEN") or "").strip() + token=(provided or "").strip() + if not expected or not token or len(expected)!=len(token): + return False + return hmac.compare_digest(token, expected) + + +async def inbox_sync_caller( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_optional_bearer)], + session: AsyncSession = Depends(get_session), +): + """JWT with inbox.edit, or CRON_INBOX_SYNC_TOKEN for the daily scheduler.""" + token=credentials.credentials if credentials else "" + if _cron_inbox_sync_token_ok(token): + return None + if credentials is None: + raise HTTPException( + status_code=401, + detail="Could not validate credentials", + headers={"WWW-Authenticate":"Bearer"}, + ) + current_user=await get_current_user(credentials,session) + checker=require_permission(PermissionTag.INBOX_EDIT) + return await checker(current_user) class AssignJobPostBody(BaseModel): @@ -104,13 +137,14 @@ async def start_email_sync( top:int=Query(100,ge=1,le=100), skip:int=Query(0,ge=0), test_on: bool = Query(True), - current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)), + current_user: dict | None = Depends(inbox_sync_caller), session: AsyncSession = Depends(get_session), ): """Enqueue mailbox sync on the dedicated mailbox_sync Taskiq queue. Returns immediately with a run id. Poll GET /email/sync/fetch until completed. - Closing the browser does not cancel the worker. + Closing the browser does not cancel the worker. The daily cron uses the same + route with CRON_INBOX_SYNC_TOKEN instead of a recruiter JWT. """ try: service=Email(session=session) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 07b54ba..017bd3d 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -162,7 +162,12 @@ class Inbox(SQLModel, table=True): @classmethod async def list_applications_by_emails(cls, session: AsyncSession, emails): - """Inbox applications whose user or sender address is in `emails`.""" + """Every inbox_messages row from these senders — linked or not. + + The applications list hides body-only mail (`attachment == True`). History + still needs those rows: a later proper CV is a new attempt, and the first + one must remain visible if the system dropped it for format. + """ from job.job_post.models import JobPosts lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()}) @@ -170,36 +175,47 @@ class Inbox(SQLModel, table=True): return [] result = await session.execute( select( - cls.id.label("inbox_id"), - cls.user_id, - cls.message_id, - Users.email, + Inbox.id.label("inbox_id"), + Inbox_Messages.id.label("message_pk"), + Inbox_Messages.message_id.label("upstream_id"), Inbox_Messages.message_from, + Users.email.label("user_email"), Inbox_Messages.assigned_job_post_id, Inbox_Messages.application_status, Inbox_Messages.message_received_time, - cls.created_at, + Inbox_Messages.created_at, + Inbox_Messages.attachment, + Inbox_Messages.match_status, JobPosts.title, ) - .join(Users, cls.user_id == Users.id) - .join(Inbox_Messages, cls.message_id == Inbox_Messages.id) + .select_from(Inbox_Messages) + .outerjoin(Inbox, Inbox.message_id == Inbox_Messages.id) + .outerjoin(Users, Inbox.user_id == Users.id) .outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id) .where(or_( - func.lower(Users.email).in_(lowers), func.lower(Inbox_Messages.message_from).in_(lowers), + func.lower(Users.email).in_(lowers), )) - .order_by(cls.created_at.desc()) + .order_by(Inbox_Messages.created_at.desc()) ) rows = [] + seen = set() for row in result.mappings().all(): - email = (row["email"] or row["message_from"] or "").strip().lower() or None + mid = row["message_pk"] + if mid in seen: + continue + seen.add(mid) + sender = (row["message_from"] or "").strip().lower() or None + user_email = (row["user_email"] or "").strip().lower() or None + email = sender if sender in lowers else (user_email if user_email in lowers else sender) status = row["application_status"] applied = row["message_received_time"] or row["created_at"] rows.append({ "source": "inbox", "email": email, "inbox_id": row["inbox_id"], - "message_id": str(row["message_id"]) if row["message_id"] else None, + "message_id": str(mid) if mid else None, + "upstream_id": str(row["upstream_id"]) if row["upstream_id"] else None, "manual_upload_candidate_id": None, "form_data_id": None, "candidate_id": None, @@ -207,6 +223,8 @@ class Inbox(SQLModel, table=True): "job_title": row["title"] or None, "status": status.value if status else None, "applied_at": applied.isoformat() if hasattr(applied, "isoformat") else (applied or None), + "attachment": bool(row["attachment"]), + "match_status": row["match_status"] or None, }) return rows @@ -1529,6 +1547,51 @@ class Inbox_Message_Triage(SQLModel, table=True): await session.refresh(row) return row + @classmethod + async def list_filtered_by_emails(cls, session: AsyncSession, emails): + """Non-ingested classifier rows for these senders. + + The inbox never listed these — either the CV could not be read or the + gate kept the mail out. History still needs the attempt, labelled as + rejected for format rather than dropped. + """ + from inbox_classifier.enums import Triage_Reason_Code + + lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()}) + if not lowers: + return [] + result = await session.execute( + select(cls) + .where(func.lower(cls.message_from).in_(lowers)) + .where(cls.ingested == False) # noqa: E712 + .where(or_( + cls.attachment == True, # noqa: E712 + cls.reason_code == Triage_Reason_Code.JOB_APPLICATION.value, + )) + .order_by(cls.classified_at.desc()) + ) + rows = [] + for rec in result.scalars().all(): + applied = rec.message_received_time or rec.classified_at + rows.append({ + "source": "filtered", + "email": (rec.message_from or "").strip().lower() or None, + "inbox_id": None, + "message_id": rec.message_id or None, + "upstream_id": rec.message_id or None, + "manual_upload_candidate_id": None, + "form_data_id": None, + "candidate_id": None, + "job_post_id": None, + "job_title": None, + "status": "WRONG_FORMAT", + "applied_at": applied.isoformat() if hasattr(applied, "isoformat") else (applied or None), + "attachment": bool(rec.attachment), + "match_status": None, + "rejection_reason": "wrong_format", + }) + return rows + @classmethod def _triage_filter(cls, statement, is_application, status, search): if is_application is not None: diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index 92212d1..e50d0d9 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -335,8 +335,51 @@ def serialize_manager_candidate(row, *, source) -> dict: } +_WRONG_FORMAT_MATCH = frozenset({"no_text", "failed", "dlq"}) + + +def is_assigned_application(row) -> bool: + """True when the row is an application to a real job, not an unassigned email. + + Reapplied means they applied to a role before. Another inbox mail with no + job post is still history; it is not a reapplication. Sheet forms name a + role in job_title even before a job post is linked. + """ + if not isinstance(row, dict): + return False + if row.get("job_post_id"): + return True + if row.get("source") == "form" and row.get("job_title"): + return True + return False + + +def rejection_reason(row) -> str | None: + """Why an unassigned attempt never reached a job — or None if it is still open. + + Wrong format: no CV, unreadable PDF, matcher failed, or the classifier + kept the mail out of the inbox. Assigned rows keep their pipeline status. + """ + if not isinstance(row, dict) or is_assigned_application(row): + return None + if row.get("rejection_reason") == "wrong_format": + return "wrong_format" + if row.get("source") == "filtered": + return "wrong_format" + match = str(row.get("match_status") or "").strip().lower() + if match in _WRONG_FORMAT_MATCH: + return "wrong_format" + if row.get("source") == "inbox" and row.get("attachment") is False: + return "wrong_format" + return None + + def serialize_application_history_item(row) -> dict: """One prior application / score / sheet row for a reapplicant lookup.""" + reason = rejection_reason(row) + status = row.get("status") + if reason == "wrong_format": + status = "WRONG_FORMAT" return { "source": row.get("source"), "inbox_id": row.get("inbox_id"), @@ -346,8 +389,11 @@ def serialize_application_history_item(row) -> dict: "candidate_id": row.get("candidate_id"), "job_post_id": row.get("job_post_id"), "job_title": row.get("job_title"), - "status": row.get("status"), + "status": status, "applied_at": row.get("applied_at"), + "rejection_reason": reason, + "match_status": row.get("match_status"), + "attachment": row.get("attachment"), } @@ -362,6 +408,6 @@ def serialize_application_history(email, *, user=None, present_in=None, applicat {"id": str(user.id), "name": user.name, "email": user.email} if user is not None else None ), - "is_reapplicant": len(items) > 0, + "is_reapplicant": any(is_assigned_application(item) for item in items), "applications": items, } diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 023e8ce..ee17be6 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -9,7 +9,7 @@ from app.core.errors import ATSError,ErrorCode from app.models.scoring import CompletedCandidate from app.services.pdf import extract_resume,sanitize_filename from app.services.scoring import score_batch -from inbox.models import Inbox_Messages,Inbox,AtsResults +from inbox.models import Inbox_Messages,Inbox,Inbox_Message_Triage,AtsResults from job.candidate.models import Candidates from job.candidate.plugins import ( FILE_NOT_FOUND, @@ -25,7 +25,7 @@ from job.candidate.plugins import ( normalize_spaced_text, ) from g_sheet.models import FormData -from job.candidate.serializers import serialize_application_history,serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate +from job.candidate.serializers import is_assigned_application,serialize_application_history,serialize_application_history_item,serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate from job.job_post.models import JobPosts from job.job_post.serializers import serialize_job_post from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE @@ -73,17 +73,23 @@ def _is_current_application(item,payload): if not isinstance(item,dict) or not isinstance(payload,dict): return False source=item.get("source") - if source=="inbox": + if source in ("inbox","filtered"): if payload.get("inbox_id") is not None and item.get("inbox_id") is not None: try: if int(payload["inbox_id"])==int(item["inbox_id"]): return True except (TypeError,ValueError): pass - pid=payload.get("message_id") - if not pid and payload.get("inbox_id") is None and payload.get("sheet") is None: - pid=payload.get("id") - return bool(pid and item.get("message_id") and str(pid)==str(item["message_id"])) + pid=payload.get("id") + if pid and item.get("message_id") and str(pid)==str(item["message_id"]): + return True + graph=payload.get("message_id") + if graph: + if item.get("upstream_id") and str(graph)==str(item["upstream_id"]): + return True + if item.get("message_id") and str(graph)==str(item["message_id"]): + return True + return False if source=="manual": pid=payload.get("manual_upload_candidate_id") if not pid and payload.get("inbox_id") is None and payload.get("sheet") is None: @@ -1615,6 +1621,7 @@ class CandidateView: users=await Users.get_users_by_emails(self.session,lowers) user_by_email={_norm_email(u.email):u for u in users} inbox_rows=await Inbox.list_applications_by_emails(self.session,lowers) + filtered_rows=await Inbox_Message_Triage.list_filtered_by_emails(self.session,lowers) manual_rows=await Manual_UPLOAD_CANDIDATE.list_by_emails(self.session,lowers) form_rows=await FormData.list_by_emails(self.session,lowers) ats_rows=await Candidates.list_by_emails(self.session,lowers) @@ -1623,10 +1630,21 @@ class CandidateView: if email in packed: packed[email]["present_in"].append("users") packed[email]["user"]=user + ingested_upstream={ + str(row.get("upstream_id")) for row in inbox_rows if row.get("upstream_id") + } for row in inbox_rows: email=_norm_email(row.get("email")) if email in packed: packed[email]["applications"].append(row) + for row in filtered_rows: + email=_norm_email(row.get("email")) + if email not in packed: + continue + upstream=str(row.get("upstream_id") or row.get("message_id") or "") + if upstream and upstream in ingested_upstream: + continue + packed[email]["applications"].append(row) for row in manual_rows: email=_norm_email(row.get("email")) if email not in packed: @@ -1697,19 +1715,8 @@ class CandidateView: for row in pack.get("applications") or []: if _is_current_application(row,payload): continue - previous.append({ - "source":row.get("source"), - "inbox_id":row.get("inbox_id"), - "message_id":row.get("message_id"), - "manual_upload_candidate_id":row.get("manual_upload_candidate_id"), - "form_data_id":row.get("form_data_id"), - "candidate_id":row.get("candidate_id"), - "job_post_id":row.get("job_post_id"), - "job_title":row.get("job_title"), - "status":row.get("status"), - "applied_at":row.get("applied_at"), - }) + previous.append(serialize_application_history_item(row)) payload["present_in"]=list(pack.get("present_in") or []) - payload["is_reapplicant"]=bool(previous) + payload["is_reapplicant"]=any(is_assigned_application(item) for item in previous) payload["previous_applications"]=previous return records[0] if single else records diff --git a/backend/taskiq_management/broker_setup.py b/backend/taskiq_management/broker_setup.py index 318665f..6b90677 100644 --- a/backend/taskiq_management/broker_setup.py +++ b/backend/taskiq_management/broker_setup.py @@ -1,7 +1,7 @@ """Taskiq broker — Redis Streams + smart retry + DLQ. -Worker: taskiq worker taskiq_management.broker_setup:broker inbox.tasks inbox.sync_tasks taskiq_management.tasks job.candidate.bank_tasks -Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler +Worker: taskiq worker taskiq_management.broker_setup:broker inbox.tasks inbox.sync_tasks cron_schdule.tasks taskiq_management.tasks job.candidate.bank_tasks +Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler inbox.sync_tasks cron_schdule.tasks """ from __future__ import annotations diff --git a/backend/tests/test_application_history.py b/backend/tests/test_application_history.py new file mode 100644 index 0000000..4316d4b --- /dev/null +++ b/backend/tests/test_application_history.py @@ -0,0 +1,87 @@ +"""Reapplicant history — badge vs kept attempts. + +No database: the helpers decide whether a prior row counts as Reapplied +and how a system-dropped attempt is labelled. +""" +from job.candidate.serializers import ( + is_assigned_application, + rejection_reason, + serialize_application_history, + serialize_application_history_item, +) +from job.candidate.views import _is_current_application + + +def test_unassigned_inbox_is_not_a_reapplication(): + row = { + "source": "inbox", + "job_post_id": None, + "job_title": None, + "status": "CLOSED", + "attachment": True, + "match_status": "matched", + } + assert is_assigned_application(row) is False + assert rejection_reason(row) is None + + +def test_assigned_inbox_is_a_reapplication(): + row = {"source": "inbox", "job_post_id": "job-1", "job_title": "Engineer"} + assert is_assigned_application(row) is True + assert rejection_reason(row) is None + + +def test_form_role_name_counts_without_job_post(): + row = {"source": "form", "job_post_id": None, "job_title": "Data Analyst"} + assert is_assigned_application(row) is True + + +def test_unreadable_cv_is_wrong_format(): + row = { + "source": "inbox", + "job_post_id": None, + "attachment": True, + "match_status": "no_text", + "status": "CLOSED", + } + assert rejection_reason(row) == "wrong_format" + item = serialize_application_history_item(row) + assert item["status"] == "WRONG_FORMAT" + assert item["rejection_reason"] == "wrong_format" + + +def test_body_only_mail_is_wrong_format(): + row = {"source": "inbox", "job_post_id": None, "attachment": False, "status": "CLOSED"} + assert rejection_reason(row) == "wrong_format" + + +def test_filtered_classifier_row_is_wrong_format(): + row = {"source": "filtered", "job_post_id": None, "status": "WRONG_FORMAT"} + assert rejection_reason(row) == "wrong_format" + history = serialize_application_history("a@x.com", applications=[row]) + assert history["is_reapplicant"] is False + assert history["applications"][0]["status"] == "WRONG_FORMAT" + + +def test_history_reapplicant_needs_an_assigned_job(): + unassigned = {"source": "inbox", "job_post_id": None, "status": "CLOSED", "attachment": True} + assigned = {"source": "inbox", "job_post_id": "job-1", "job_title": "Engineer", "status": "PENDING"} + only_mail = serialize_application_history("a@x.com", applications=[unassigned]) + assert only_mail["is_reapplicant"] is False + assert len(only_mail["applications"]) == 1 + both = serialize_application_history("a@x.com", applications=[unassigned, assigned]) + assert both["is_reapplicant"] is True + assert len(both["applications"]) == 2 + + +def test_current_inbox_row_matches_message_pk(): + item = {"source": "inbox", "message_id": "11111111-1111-1111-1111-111111111111"} + payload = {"id": "11111111-1111-1111-1111-111111111111", "email": "a@x.com"} + assert _is_current_application(item, payload) is True + assert _is_current_application(item, {"id": "other"}) is False + + +def test_filtered_row_matches_graph_id_on_detail_payload(): + item = {"source": "filtered", "message_id": "AAMkGraph", "upstream_id": "AAMkGraph"} + payload = {"message_id": "AAMkGraph", "fromEmail": "a@x.com"} + assert _is_current_application(item, payload) is True diff --git a/backend/tests/test_cron_inbox_sync.py b/backend/tests/test_cron_inbox_sync.py new file mode 100644 index 0000000..a31af5e --- /dev/null +++ b/backend/tests/test_cron_inbox_sync.py @@ -0,0 +1,72 @@ +"""cron_schdule.plugins.call_inbox_sync_api — httpx POST /email/sync, no live network.""" + +from __future__ import annotations + +import httpx + +from cron_schdule import plugins + + +class _FakeClient: + def __init__(self, response, calls): + self._response=response + self.calls=calls + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return None + + async def post(self, url, **kwargs): + self.calls.append((url, kwargs)) + return self._response + + +async def test_call_inbox_sync_api_posts_email_sync(monkeypatch): + monkeypatch.setenv("BACKEND_URL","http://backend-api:8000") + monkeypatch.setenv("CRON_INBOX_SYNC_TOKEN","cron-secret") + calls=[] + response=httpx.Response( + 200, + json={"data":{"status":"queued","id":"run-1"},"total":1,"status_code":200}, + request=httpx.Request("POST","http://backend-api:8000/email/sync"), + ) + monkeypatch.setattr( + plugins.httpx, + "AsyncClient", + lambda *a, **k: _FakeClient(response, calls), + ) + payload=await plugins.call_inbox_sync_api(top=50, skip=0, test_on=True) + assert payload["data"]["status"]=="queued" + assert calls[0][0]=="http://backend-api:8000/email/sync" + assert calls[0][1]["headers"]["Authorization"]=="Bearer cron-secret" + assert calls[0][1]["params"]["top"]==50 + assert calls[0][1]["params"]["test_on"] is True + + +async def test_call_inbox_sync_api_requires_token(monkeypatch): + monkeypatch.setenv("BACKEND_URL","http://backend-api:8000") + monkeypatch.delenv("CRON_INBOX_SYNC_TOKEN", raising=False) + try: + await plugins.call_inbox_sync_api() + raise AssertionError("expected RuntimeError") + except RuntimeError as e: + assert "CRON_INBOX_SYNC_TOKEN" in str(e) + + +async def test_call_inbox_sync_api_raises_on_http_error(monkeypatch): + monkeypatch.setenv("BACKEND_URL","http://backend-api:8000") + monkeypatch.setenv("CRON_INBOX_SYNC_TOKEN","cron-secret") + request=httpx.Request("POST","http://backend-api:8000/email/sync") + response=httpx.Response(401, text="unauthorized", request=request) + monkeypatch.setattr( + plugins.httpx, + "AsyncClient", + lambda *a, **k: _FakeClient(response, []), + ) + try: + await plugins.call_inbox_sync_api() + raise AssertionError("expected HTTPStatusError") + except httpx.HTTPStatusError as e: + assert e.response.status_code==401 diff --git a/backend/tests/test_g_sheet_decorators.py b/backend/tests/test_g_sheet_decorators.py new file mode 100644 index 0000000..9ab71a3 --- /dev/null +++ b/backend/tests/test_g_sheet_decorators.py @@ -0,0 +1,165 @@ +"""Unit tests for @extract_drive_cvs. No Google, no DB.""" + +from __future__ import annotations + +from pathlib import Path + +from g_sheet.decorators import ( + build_extracted_data, + extract_drive_cvs, + make_job_temp_dir, + remove_job_temp_dir, + _should_ingest, +) + + +def test_build_extracted_data_json_shape(): + payload = build_extracted_data( + status="completed", + resume_link="https://drive.google.com/open?id=abc", + file_id="abc", + filename="cv.pdf", + mime_type="application/pdf", + text="Ada Lovelace", + page_count=2, + truncated=False, + ) + assert payload["status"] == "completed" + assert payload["file_id"] == "abc" + assert payload["text"] == "Ada Lovelace" + assert payload["char_count"] == len("Ada Lovelace") + assert payload["error_code"] is None + assert payload["extracted_at"] + + +def test_should_ingest_skips_enqueue_and_empty(): + assert _should_ingest({"status": "queued", "tab": "x"}) is False + assert _should_ingest({"tab": "x", "rows_read": 0, "inserted": 0}) is False + assert _should_ingest({"tab": "x", "error": "boom"}) is False + assert _should_ingest(object()) is False + assert _should_ingest({"tab": "x", "rows_read": 4, "inserted": 4}) is True + + +async def test_extract_drive_cvs_runs_after_import(monkeypatch): + calls = [] + + async def fake_ingest(session, sheet, credentials): + calls.append((sheet, credentials)) + return {"extracted": 2, "extract_failed": 1} + + monkeypatch.setattr("g_sheet.decorators.ingest_form_resume_links", fake_ingest) + monkeypatch.setattr("g_sheet.decorators._prepare_drive_credentials", lambda service: "creds") + + class Service: + session = object() + spreadsheet_id = "sheet-id" + credentials = None + credentials_path = None + scopes = None + + @extract_drive_cvs + async def import_sheet(self, tab): + return {"tab": tab, "rows_read": 3, "inserted": 3} + + out = await Service().import_sheet("Form Responses") + assert calls == [("Form Responses", "creds")] + assert out["extracted"] == 2 + assert out["extract_failed"] == 1 + + +async def test_extract_drive_cvs_http_enqueue_does_not_ingest(monkeypatch): + calls = [] + + async def fake_ingest(*args, **kwargs): + calls.append(True) + return {"extracted": 1, "extract_failed": 0} + + monkeypatch.setattr("g_sheet.decorators.ingest_form_resume_links", fake_ingest) + monkeypatch.setattr("g_sheet.decorators._prepare_drive_credentials", lambda service: "creds") + + @extract_drive_cvs + async def import_all_sheets(tab=None, session=None): + return object() + + result = await import_all_sheets(tab="Form Responses", session=object()) + assert result is not None + assert calls == [] + + +def test_job_temp_dir_removed_after_cleanup(tmp_path): + job_dir = make_job_temp_dir(tmp_path) + leftover = job_dir / "abc.pdf" + leftover.write_bytes(b"%PDF-1.4 leftover") + assert leftover.is_file() + remove_job_temp_dir(job_dir) + assert not job_dir.exists() + assert list(tmp_path.iterdir()) == [] + + +async def test_ingest_removes_job_dir_when_finished(monkeypatch, tmp_path): + from g_sheet.decorators import ingest_form_resume_links + + async def fake_links(session, sheet): + return [("id-1", "https://drive.google.com/open?id=abc")] + + async def fake_extract(credentials, resume_link, dest_dir, max_chars=None, max_bytes=None): + path = Path(dest_dir) / "abc.pdf" + path.write_bytes(b"%PDF-fake") + path.unlink() + return build_extracted_data( + status="completed", + resume_link=resume_link, + file_id="abc", + text="ok", + page_count=1, + truncated=False, + ) + + async def fake_set(session, record_id, payload, *, commit=True): + return object() + + monkeypatch.setattr("g_sheet.decorators.FormData.fetch_resume_links", fake_links) + monkeypatch.setattr("g_sheet.decorators.extract_one_resume", fake_extract) + monkeypatch.setattr("g_sheet.decorators.FormData.set_extracted_data", fake_set) + + stats = await ingest_form_resume_links(object(), "tab", "creds", temp_root=tmp_path) + assert stats["extracted"] == 1 + assert list(tmp_path.iterdir()) == [] + + +async def test_ingest_rolls_back_after_persist_failure(monkeypatch, tmp_path): + from g_sheet.decorators import ingest_form_resume_links + + class Session: + def __init__(self): + self.rolled = 0 + + async def rollback(self): + self.rolled += 1 + + async def fake_links(session, sheet): + return [("id-1", "https://drive.google.com/open?id=abc")] + + async def fake_extract(credentials, resume_link, dest_dir, max_chars=None, max_bytes=None): + return build_extracted_data( + status="completed", + resume_link=resume_link, + file_id="abc", + text="ok", + page_count=1, + truncated=False, + ) + + async def fake_set(session, record_id, payload, *, commit=True): + raise RuntimeError("column type mismatch") + + monkeypatch.setattr("g_sheet.decorators.FormData.fetch_resume_links", fake_links) + monkeypatch.setattr("g_sheet.decorators.extract_one_resume", fake_extract) + monkeypatch.setattr("g_sheet.decorators.FormData.set_extracted_data", fake_set) + + session = Session() + stats = await ingest_form_resume_links(session, "tab", "creds", temp_root=tmp_path) + assert stats["extracted"] == 0 + assert stats["extract_failed"] == 1 + assert session.rolled == 1 + assert list(tmp_path.iterdir()) == [] diff --git a/backend/tests/test_g_sheet_scoring.py b/backend/tests/test_g_sheet_scoring.py new file mode 100644 index 0000000..585a149 --- /dev/null +++ b/backend/tests/test_g_sheet_scoring.py @@ -0,0 +1,123 @@ +"""Unit tests for form ATS scoring helpers. No OpenAI, no DB.""" + +from __future__ import annotations + +import uuid + +from g_sheet.scoring import ( + enqueue_form_scores, + resume_text_from_extracted, + serialize_form_ats, + _band, +) + + +def test_resume_text_from_extracted_requires_completed_text(): + assert resume_text_from_extracted(None) is None + assert resume_text_from_extracted("x") is None + assert resume_text_from_extracted({"status": "failed", "text": "Ada"}) is None + assert resume_text_from_extracted({"status": "completed", "text": " "}) is None + assert resume_text_from_extracted({"status": "completed", "text": " Ada Lovelace "}) == "Ada Lovelace" + + +def test_band_thresholds(): + assert _band(None) == "" + assert _band(50) == "Weak Match" + assert _band(65) == "Potential Match" + assert _band(82) == "Strong Match" + + +def test_serialize_form_ats_shape(): + job_id = uuid.uuid4() + + class Row: + job_post_id = job_id + overall_score = 81.0 + band = "Potential Match" + computed_at = None + + assert serialize_form_ats(Row()) == { + "job_post_id": str(job_id), + "overall_score": 81.0, + "band": "Potential Match", + "computed_at": None, + } + + +async def test_enqueue_form_scores_queues_each_job(monkeypatch): + queued = [] + + class Kicker: + def with_labels(self, **kwargs): + return self + + async def kiq(self, form_id, job_id): + queued.append((form_id, job_id)) + + class Task: + def kicker(self): + return Kicker() + + monkeypatch.setattr("inbox.tasks.score_form_data", Task()) + form_id = uuid.uuid4() + job_a = uuid.uuid4() + job_b = uuid.uuid4() + await enqueue_form_scores(form_id, [job_a, str(job_b), job_a, None]) + assert queued == [(str(form_id), str(job_a)), (str(form_id), str(job_b))] + + +async def test_enqueue_form_row_scores_assigned_skips_suggested(monkeypatch): + queued = [] + + class Kicker: + def with_labels(self, **kwargs): + return self + + async def kiq(self, form_id, job_id): + queued.append((form_id, job_id)) + + class Task: + def kicker(self): + return Kicker() + + monkeypatch.setattr("inbox.tasks.score_form_data", Task()) + + class Row: + id = uuid.uuid4() + assigned_job_post_id = uuid.uuid4() + job_post_id = None + suggested_job_post_ids = [str(uuid.uuid4()), str(uuid.uuid4())] + + from g_sheet.scoring import enqueue_form_row_scores + await enqueue_form_row_scores(Row()) + assert queued == [(str(Row.id), str(Row.assigned_job_post_id))] + + +async def test_enqueue_form_row_scores_suggested_when_unassigned(monkeypatch): + queued = [] + + class Kicker: + def with_labels(self, **kwargs): + return self + + async def kiq(self, form_id, job_id): + queued.append((form_id, job_id)) + + class Task: + def kicker(self): + return Kicker() + + monkeypatch.setattr("inbox.tasks.score_form_data", Task()) + job_a = uuid.uuid4() + job_b = uuid.uuid4() + + class Row: + id = uuid.uuid4() + assigned_job_post_id = None + job_post_id = None + suggested_job_post_ids = [str(job_a), str(job_b)] + + from g_sheet.scoring import enqueue_form_row_scores + row = Row() + await enqueue_form_row_scores(row) + assert queued == [(str(row.id), str(job_a)), (str(row.id), str(job_b))] diff --git a/backend/tests/test_inbox_phone.py b/backend/tests/test_inbox_phone.py new file mode 100644 index 0000000..22d1a1b --- /dev/null +++ b/backend/tests/test_inbox_phone.py @@ -0,0 +1,47 @@ +"""employment_agent.plugins parse_phone — stacked clamp_phone + prefer_extracted_phone.""" + +from __future__ import annotations + +from employment_agent.plugins import parse_phone, prefer_full_phone + + +def _digits(value): + return "".join(c for c in (value or "") if c.isdigit()) + + +def test_pk_local_keeps_all_eleven_digits(): + fields=parse_phone({},"Ali 0321-5551234 Engineer") + assert _digits(fields["phone"])=="03215551234" + + +def test_plus_ninety_two_keeps_last_group(): + fields=parse_phone({},"Phone: +92 333 123 4567") + digits=_digits(fields["phone"]) + assert digits.endswith("1234567") + assert len(digits)>=12 + + +def test_pdf_wrapped_last_three_digits_are_kept(): + fields=parse_phone({},"Mobile: 0300-1234\n567") + assert _digits(fields["phone"])=="03001234567" + + +def test_does_not_swallow_a_year_after_the_number(): + fields=parse_phone({},"0321-5551234 2018 — 2024 Engineer") + assert _digits(fields["phone"])=="03215551234" + + +def test_four_three_four_grouping_is_complete(): + fields=parse_phone({},"Cell: 0301 234 5678") + assert _digits(fields["phone"])=="03012345678" + + +def test_form_phone_without_resume_is_kept_when_complete(): + fields=parse_phone({"phone":"0321-5551234"},"") + assert _digits(fields["phone"])=="03215551234" + + +def test_prefer_full_phone_picks_the_longer_complete_number(): + assert prefer_full_phone("0300-1234","0300-1234567")=="0300-1234567" + assert prefer_full_phone(None,"0321-5551234")=="0321-5551234" + assert prefer_full_phone("0300-123",None) is None diff --git a/backend/tests/test_requisition_scope.py b/backend/tests/test_requisition_scope.py new file mode 100644 index 0000000..56ffea2 --- /dev/null +++ b/backend/tests/test_requisition_scope.py @@ -0,0 +1,246 @@ +"""Requisition-owner job/candidate scope — unit + live DB checks. + +Run from the API container (PYTHONPATH=/app): + + python tests/test_requisition_scope.py +""" + +from __future__ import annotations + +import asyncio +import sys + +from sqlalchemy import select + +from job.candidate.views import owned_job_ids_for_candidate_scope +from job.job_post.models import JobPosts +from job.job_post.views import JobPost +from role.models import Roles +from users.models import Users +from users.permissions import ( + scopes_to_own_requisitions, + sees_all_candidates, + is_hiring_manager, + is_admin, +) + + +def _ok(name: str, cond: bool, extra: str = "") -> None: + global failed + if cond: + print(f"ok {name}" + (f" {extra}" if extra else "")) + else: + failed += 1 + print(f"FAIL {name}" + (f" {extra}" if extra else "")) + + +failed = 0 + + +def test_helper_unit() -> None: + recruiter = { + "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "role_name": "recruiter", + "permissions": ["candidates.view", "jobs.view"], + } + _ok("recruiter is not requisition-scoped", not scopes_to_own_requisitions(recruiter)) + _ok("recruiter does not see all candidates", not sees_all_candidates(recruiter)) + + custom = { + **recruiter, + "role_name": "AI_TEAM_MANAGER", + "permissions": ["candidates.view", "jobs.view", "requisitions.create"], + } + _ok("requisitions.create alone does not scope jobs/candidates", not scopes_to_own_requisitions(custom)) + _ok("custom role is not hiring-manager portal", not is_hiring_manager(custom)) + _ok("custom role is not admin", not is_admin(custom)) + _ok("custom role does not see all candidates", not sees_all_candidates(custom)) + + configured = { + **custom, + "permissions": ["candidates.view", "jobs.view", "requisitions.create", "requisitions.configure"], + } + _ok("Access Control requisitions.configure enables the scope", scopes_to_own_requisitions(configured)) + + manage = { + **custom, + "permissions": ["candidates.view", "candidates.manage", "requisitions.configure"], + } + _ok( + "candidates.manage wins over requisitions.configure", + not scopes_to_own_requisitions(manage) and sees_all_candidates(manage), + ) + + admin = { + "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "role_name": "admin", + "permissions": ["requisitions.create", "requisitions.manage"], + } + _ok("admin is not requisition-scoped", not scopes_to_own_requisitions(admin) and is_admin(admin)) + + hm = { + "id": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "role_name": "hiring_manager", + "permissions": ["candidates.view"], + } + _ok("hiring_manager is requisition-scoped", scopes_to_own_requisitions(hm) and is_hiring_manager(hm)) + + manager_named = {**hm, "role_name": "Manager"} + _ok("Manager role name is requisition-scoped", scopes_to_own_requisitions(manager_named)) + + req_manage = { + "id": "dddddddd-dddd-dddd-dddd-dddddddddddd", + "role_name": "ops_lead", + "permissions": ["requisitions.manage", "candidates.view"], + } + _ok( + "requisitions.manage is admin, not this scope", + is_admin(req_manage) and not scopes_to_own_requisitions(req_manage), + ) + + +async def test_live_db() -> None: + from candidate_forms.models import Requisition + from db_setup import session_scope + from inbox.models import Inbox_Messages + from job.candidate.views import CandidateView + + async with session_scope() as session: + # Job opened from a requisition this user created, but job_posts.created_by + # is someone else: recruiter/creator scope hides it; requisition scope + # must still show it (recruiter assignment on the job is irrelevant). + stmt = ( + select(JobPosts, Requisition) + .join(Requisition, JobPosts.requisition_id == Requisition.id) + .where( + JobPosts.is_deleted == False, # noqa: E712 + Requisition.is_deleted == False, # noqa: E712 + Requisition.created_by != JobPosts.created_by, + ) + .limit(1) + ) + row = (await session.execute(stmt)).first() + if not row: + print("skip live job whose requisition creator is not job_posts.created_by") + else: + job, req = row + owner = { + "id": str(req.created_by), + "role_name": "AI_TEAM_MANAGER", + "permissions": ["candidates.view", "jobs.view", "requisitions.configure"], + } + recruiter_only = { + "id": str(req.created_by), + "role_name": "recruiter", + "permissions": ["candidates.view", "jobs.view"], + } + manage_all = { + "id": str(req.created_by), + "role_name": "AI_TEAM_MANAGER", + "permissions": ["candidates.view", "candidates.manage"], + } + owned = await owned_job_ids_for_candidate_scope(session, owner) + creator = await owned_job_ids_for_candidate_scope(session, recruiter_only) + unscoped = await owned_job_ids_for_candidate_scope(session, manage_all) + _ok( + "requisition owner sees job they did not create as job_posts.created_by", + job.id in set(owned or []), + f"job={job.title!r} req_owner={req.created_by} job_created_by={job.created_by} recruiter={job.current_recruiter_id}", + ) + _ok( + "without requisitions.create, recruiter/creator filter hides that job", + job.id not in set(creator or []), + ) + _ok("candidates.manage leaves the list unscoped", unscoped is None) + + applicants = await Inbox_Messages.counts_by_job_post_ids(session, [job.id]) + n = applicants.get(str(job.id), 0) + print(f"info applicants on that job: {n}") + + service = JobPost(session) + jobs_rows, jobs_total = await service.fetch_jobs( + active_only=False, top=500, skip=0, current_user=owner + ) + job_ids = {str(r.get("id") or "") for r in jobs_rows} + _ok( + "/jobs/fetch for requisition owner includes the job", + str(job.id) in job_ids, + f"total={jobs_total}", + ) + _, all_total = await service.fetch_jobs( + active_only=False, top=500, skip=0, current_user=manage_all + ) + _ok( + "candidates.manage /jobs/fetch is wider than requisition scope", + all_total > jobs_total, + f"scoped={jobs_total} unscoped={all_total}", + ) + + cand_view = CandidateView(session) + scoped_rows = await cand_view.get_candidate( + current_user=owner, limit=100, offset=0 + ) + all_cand = await cand_view.get_candidate( + current_user=manage_all, limit=100, offset=0 + ) + scoped_n = len(scoped_rows) if isinstance(scoped_rows, list) else 0 + all_n = len(all_cand) if isinstance(all_cand, list) else 0 + _ok( + "candidate list for requisition owner is non-empty when the job has applicants", + (n == 0) or scoped_n > 0, + f"scoped_candidates={scoped_n}", + ) + _ok( + "candidates.manage sees at least the requisition-scoped rows", + all_n >= scoped_n, + f"scoped={scoped_n} unscoped={all_n}", + ) + + user = await Users.get_user_by_email(session, "new@utopiabrands.com") + if not user: + print("skip live new@utopiabrands.com (user missing)") + return + tags = await Roles.resolve_tags(session, user.role) + role = getattr(user, "role", None) + live = { + "id": str(user.id), + "role_name": getattr(role.role_name, "value", role.role_name) if role is not None else None, + "permissions": list(tags), + } + print( + f"info live user {user.email} role={live['role_name']!r} " + f"requisitions.create={'requisitions.create' in tags} " + f"requisitions.configure={'requisitions.configure' in tags} " + f"candidates.manage={'candidates.manage' in tags}" + ) + _ok( + "live AI Team Manager is not hiring-manager portal", + not is_hiring_manager(live), + f"role={live['role_name']!r}", + ) + scoped = scopes_to_own_requisitions(live) + print(f"info scopes_to_own_requisitions={scoped}") + owned = await owned_job_ids_for_candidate_scope(session, live) + if owned is None: + print("info live candidate list is unscoped (admin or candidates.manage)") + else: + print(f"info live owned job ids: {len(owned)}") + if "requisitions.configure" not in tags and not scoped: + print( + "info tick Requisitions → Configure on this role in Access Control " + "to enable requisition-owner job/candidate scope" + ) + + +def main() -> int: + test_helper_unit() + asyncio.run(test_live_db()) + if failed: + print(f"\n{failed} failed") + return 1 + print("\nall passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docker-compose.yml b/docker-compose.yml index bd40d62..b545464 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -234,6 +234,7 @@ services: "taskiq_management.broker_setup:broker", "inbox.tasks", "inbox.sync_tasks", + "cron_schdule.tasks", "taskiq_management.tasks", "--workers", "1", @@ -253,6 +254,7 @@ services: "scheduler", "taskiq_management.broker_setup:scheduler", "inbox.sync_tasks", + "cron_schdule.tasks", ] environment: <<: *backend-env diff --git a/frontend/src/components/ReapplicantHistory.jsx b/frontend/src/components/ReapplicantHistory.jsx index c838e29..5a6f1b5 100644 --- a/frontend/src/components/ReapplicantHistory.jsx +++ b/frontend/src/components/ReapplicantHistory.jsx @@ -7,6 +7,7 @@ const SOURCE_LABEL = { manual: 'Manual', form: 'Form', ats: 'ATS', + filtered: 'Email', } const STAGE_BADGE = { @@ -19,9 +20,18 @@ const STAGE_BADGE = { Hired: 'b-green', 'On Hold': 'b-amber', Rejected: 'b-gray', + 'Rejected — wrong format': 'b-red', + 'No job assigned': 'b-gray', } -export function applicationStatusLabel(status) { +export function applicationStatusLabel(status, item) { + if (item?.rejection_reason === 'wrong_format' || String(status || '').toUpperCase() === 'WRONG_FORMAT') { + return 'Rejected — wrong format' + } + const assigned = Boolean(item?.job_post_id || item?.jobPostId || (item?.source === 'form' && (item.job_title || item.jobTitle))) + if (!assigned && (status == null || status === '' || String(status).toUpperCase() === 'CLOSED')) { + return 'No job assigned' + } if (status == null || status === '') return 'Shortlist' const key = String(status).toUpperCase() if (STAGE_FROM_STATUS[key]) return STAGE_FROM_STATUS[key] @@ -44,25 +54,32 @@ export function previousApplicationsOf(row) { return [] } +function hasAssignedJob(item) { + if (!item) return false + if (item.job_post_id || item.jobPostId) return true + return item.source === 'form' && Boolean(item.job_title || item.jobTitle) +} + export function isReapplicant(row) { if (!row) return false - if (row.isReapplicant === true || row.is_reapplicant === true) return true - return previousApplicationsOf(row).length > 0 + const previous = previousApplicationsOf(row) + if (previous.length) return previous.some(hasAssignedJob) + return row.isReapplicant === true || row.is_reapplicant === true } export function previousApplicationsTip(row) { const items = previousApplicationsOf(row) if (!items.length) return 'Applied before' return items.map((item) => { - const job = item.job_title || item.jobTitle || 'Unassigned job' - return `${job} — ${applicationStatusLabel(item.status)}` + const job = item.job_title || item.jobTitle || 'No job assigned' + return `${job} — ${applicationStatusLabel(item.status, item)}` }).join('\n') } /** Compact chip for tables, kanban cards, and inbox rows. */ export function ReappliedBadge({ row, className = '' }) { if (!isReapplicant(row)) return null - const count = previousApplicationsOf(row).length + const count = previousApplicationsOf(row).filter(hasAssignedJob).length return (
{items.map((item, idx) => { - const stage = applicationStatusLabel(item.status) + const stage = applicationStatusLabel(item.status, item) const key = [ item.source, item.inbox_id,