diff --git a/backend/g_sheet/app.py b/backend/g_sheet/app.py index 2eee20f..7a8c737 100644 --- a/backend/g_sheet/app.py +++ b/backend/g_sheet/app.py @@ -167,6 +167,14 @@ class AssignFormJobPostBody(BaseModel): job_post_id: str | None = None +class FormProcessingStateBody(BaseModel): + processing_state: str + + +class FormDuplicateBody(BaseModel): + is_duplicate: bool + + @router.get("/sheet/form-data/sheets") async def fetch_form_data_sheets( current_user: dict = Depends(_FORM_DATA_READ), @@ -186,6 +194,8 @@ async def fetch_form_data_sheets( async def fetch_form_data( sheet: str | None = Query(None), search: str | None = Query(None), + processing_state: str | None = Query(None), + is_duplicate: bool | None = Query(None), offset: int = Query(0,ge=0), limit: int | None = Query(None,ge=1), current_user: dict = Depends(_FORM_DATA_READ), @@ -193,7 +203,10 @@ async def fetch_form_data( ): try: service=SheetFormData(session=session) - items,total=await service.get_form_data(sheet=sheet,search=search,offset=offset,limit=limit) + items,total=await service.get_form_data( + sheet=sheet,search=search,offset=offset,limit=limit, + processing_state=processing_state,is_duplicate=is_duplicate, + ) return JSONResponse(content={"data":items,"total":total,"status_code":200}) except HTTPException: raise @@ -201,6 +214,22 @@ async def fetch_form_data( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/sheet/form-data/counts") +async def fetch_form_data_counts( + sheet: str | None = Query(None), + current_user: dict = Depends(_FORM_DATA_READ), + session: AsyncSession = Depends(get_session), +): + try: + service=SheetFormData(session=session) + data=await service.get_counts(sheet=sheet) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.get("/sheet/form-data/{record_id}") async def fetch_form_data_by_id( record_id: str, @@ -234,6 +263,40 @@ async def assign_form_job_post( raise HTTPException(status_code=500,detail=str(e)) +@router.patch("/sheet/form-data/{record_id}/processing-state") +async def set_form_processing_state( + record_id: str, + payload: FormProcessingStateBody, + current_user: dict = Depends(_FORM_DATA_EDIT), + session: AsyncSession = Depends(get_session), +): + try: + service=SheetFormData(session=session) + data=await service.set_processing_state(record_id,payload.processing_state) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/sheet/form-data/{record_id}/duplicate") +async def set_form_duplicate( + record_id: str, + payload: FormDuplicateBody, + current_user: dict = Depends(_FORM_DATA_EDIT), + session: AsyncSession = Depends(get_session), +): + try: + service=SheetFormData(session=session) + data=await service.set_duplicate(record_id,payload.is_duplicate) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.delete("/sheet/form-data/{tab}/delete") async def delete_form_data_sheet( tab: str, diff --git a/backend/g_sheet/enums.py b/backend/g_sheet/enums.py index acac5bf..6d97be6 100644 --- a/backend/g_sheet/enums.py +++ b/backend/g_sheet/enums.py @@ -171,6 +171,7 @@ class FormDataColumn(str, Enum): ID = "id" SHEET = "sheet" JOB_POST_ID = "job_post_id" + MANUAL_UPLOAD_CANDIDATE_ID = "manual_upload_candidate_id" ROW_NUMBER = "row_number" SERIAL_NO = "serial_no" ENTRY_YEAR = "entry_year" @@ -216,6 +217,9 @@ class FormDataColumn(str, Enum): DIRECTOR_POC_CATEGORY = "director_poc_category" PROS = "pros" CONS = "cons" + # Same vocabulary as inbox_messages — Import / Shortlist / Reject / Duplicate. + PROCESSING_STATE = "processing_state" + IS_DUPLICATE = "is_duplicate" RAW_RECORD = "raw_record" IMPORTED_AT = "imported_at" CREATED_AT = "created_at" diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index f3a6350..6e2d018 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -5,7 +5,7 @@ from __future__ import annotations import uuid from datetime import datetime, timezone -from sqlalchemy import Column, DateTime, Index, delete, func, insert, or_ +from sqlalchemy import Column, DateTime, Index, case, delete, func, insert, or_ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, SQLModel, select @@ -31,6 +31,9 @@ class FormData(SQLModel, table=True): # Optional link to a job post. DB FK only — no ORM Relationship (avoids # pulling job_posts into the sheet worker metadata graph). job_post_id: uuid.UUID | None = Field(default=None, index=True) + # Set when this form row is promoted into the hiring pipeline (Users + + # manual_upload_candidate). Idempotency key for assign / shortlist. + manual_upload_candidate_id: uuid.UUID | None = Field(default=None, index=True) row_number: int | None = Field(default=None) serial_no: str | None = Field(default=None) entry_year: str | None = Field(default=None) @@ -77,16 +80,25 @@ class FormData(SQLModel, table=True): pros: str | None = Field(default=None) cons: str | None = Field(default=None) + # Same allowlist as inbox_messages.processing_state: unread|imported|processed|rejected. + # server_default is load-bearing — ALTER on a populated form_data table. + processing_state: str = Field(default="unread", sa_column_kwargs={"server_default": "unread"}) + is_duplicate: bool = Field(default=False, sa_column_kwargs={"server_default": "false"}) + raw_record: dict | None = Field(default=None, sa_column=Column(JSONB)) imported_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) @classmethod - def _filters(cls, *, sheet=None, search=None): + def _filters(cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None): filters = [] if sheet: filters.append(cls.sheet == sheet) + if processing_state: + filters.append(cls.processing_state == processing_state) + if is_duplicate is not None: + filters.append(cls.is_duplicate == bool(is_duplicate)) if search: # Twelve unanchored ILIKEs over ~26k rows is a sequential scan of a few # tens of ms — acceptable at this size; a pg_trgm GIN index is the @@ -139,26 +151,101 @@ class FormData(SQLModel, table=True): return row @classmethod - async def fetch_form_data(cls, session: AsyncSession, *, sheet=None, search=None, offset=0, limit=None): + async def set_processing_state(cls, session: AsyncSession, record_id, processing_state: str): + row = await cls.get_form_data_by_id(session, record_id) + if not row: + return None + row.processing_state = processing_state + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def set_duplicate(cls, session: AsyncSession, record_id, is_duplicate: bool): + row = await cls.get_form_data_by_id(session, record_id) + if not row: + return None + row.is_duplicate = bool(is_duplicate) + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def link_manual_upload(cls, session: AsyncSession, record_id, manual_upload_candidate_id, *, commit: bool = True): + row = await cls.get_form_data_by_id(session, record_id) + if not row: + return None + try: + row.manual_upload_candidate_id = uuid.UUID(str(manual_upload_candidate_id)) + except (TypeError, ValueError): + return None + row.updated_at = _now() + session.add(row) + if commit: + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def fetch_form_data( + cls, session: AsyncSession, *, sheet=None, search=None, + processing_state=None, is_duplicate=None, offset=0, limit=None, + ): statement = select(cls).order_by(cls.sheet, cls.row_number) - for clause in cls._filters(sheet=sheet, search=search): + for clause in cls._filters( + sheet=sheet, search=search, + processing_state=processing_state, is_duplicate=is_duplicate, + ): statement = statement.where(clause) if offset: statement = statement.offset(offset) if limit is not None: statement = statement.limit(limit) - statement=statement.order_by(cls.row_number) + statement = statement.order_by(cls.row_number) result = await session.execute(statement) return result.scalars().all() @classmethod - async def count_form_data(cls, session: AsyncSession, *, sheet=None, search=None): + async def count_form_data( + cls, session: AsyncSession, *, sheet=None, search=None, + processing_state=None, is_duplicate=None, + ): statement = select(func.count()).select_from(cls) - for clause in cls._filters(sheet=sheet, search=search): + for clause in cls._filters( + sheet=sheet, search=search, + processing_state=processing_state, is_duplicate=is_duplicate, + ): statement = statement.where(clause) result = await session.execute(statement) return result.scalar_one() + @classmethod + async def count_processing(cls, session: AsyncSession, *, sheet=None): + """Tab badge counts for the Sheet Forms channel.""" + statement = select( + func.count().label("all"), + func.coalesce(func.sum(case((cls.processing_state == "unread", 1), else_=0)), 0).label("unread"), + func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"), + func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"), + func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"), + func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712 + ).select_from(cls) + if sheet: + statement = statement.where(cls.sheet == sheet) + row = (await session.execute(statement)).one() + return { + "all": int(row.all or 0), + "unread": int(row.unread or 0), + "imported": int(row.imported or 0), + "processed": int(row.processed or 0), + "rejected": int(row.rejected or 0), + "duplicates": int(row.duplicates or 0), + } + @classmethod async def get_sheet_names(cls, session: AsyncSession): result = await session.execute( diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index dd31024..725fb0f 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -368,12 +368,19 @@ class SheetFormData(Sheet): item["assigned_job_post"]=assigned_map.get(str(aid)) if aid else None return items - async def get_form_data(self,sheet=None,search=None,offset=0,limit=None): + async def get_form_data( + self,sheet=None,search=None,offset=0,limit=None, + processing_state=None,is_duplicate=None, + ): session=self._require_session() rows=await FormData.fetch_form_data( session,sheet=sheet,search=search,offset=offset,limit=limit, + processing_state=processing_state,is_duplicate=is_duplicate, + ) + total=await FormData.count_form_data( + session,sheet=sheet,search=search, + processing_state=processing_state,is_duplicate=is_duplicate, ) - total=await FormData.count_form_data(session,sheet=sheet,search=search) items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows]) return items,total @@ -386,7 +393,11 @@ class SheetFormData(Sheet): return items[0] async def assign_job_post(self,record_id,job_post_id): - """Set or clear form_data.job_post_id (same contract as inbox assign).""" + """Set or clear form_data.job_post_id (same contract as inbox assign). + + Setting a job promotes the row into Users + manual_upload_candidate so + Candidates / Talent Pool / Pipeline can see it (platform tag: Form). + """ session=self._require_session() if job_post_id is not None: from job.job_post.models import JobPosts @@ -396,8 +407,106 @@ class SheetFormData(Sheet): updated=await FormData.set_job_post(session,record_id,job_post_id) if not updated: raise HTTPException(status_code=404,detail="Form data not found") + if job_post_id is not None: + await self._promote_to_application(updated) return await self.get_form_data_by_id(record_id) + async def set_processing_state(self,record_id,processing_state): + allowed=("unread","imported","processed","rejected") + if processing_state not in allowed: + raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}") + session=self._require_session() + row=await FormData.get_form_data_by_id(session,record_id) + if not row: + raise HTTPException(status_code=404,detail="Form data not found") + # Shortlist requires a job — promote (idempotent) then flip the queue label. + if processing_state=="processed": + if not row.job_post_id: + raise HTTPException(status_code=422,detail="Assign a job post before moving to shortlist") + await self._promote_to_application(row) + updated=await FormData.set_processing_state(session,record_id,processing_state) + if not updated: + raise HTTPException(status_code=404,detail="Form data not found") + return await self.get_form_data_by_id(record_id) + + async def _promote_to_application(self,form_row): + """Create Users + manual_upload_candidate from a form_data row (idempotent). + + Pipeline / Candidates / Talent Pool all read manual_upload_candidate (or + the CANDIDATE user it creates). platform='Form' is the source badge. + """ + session=self._require_session() + from job.candidate.models import Manual_UPLOAD_CANDIDATE + from job.history.views import HistoryRecorder + from job.history.enums import HistoryEvent + + if getattr(form_row,"manual_upload_candidate_id",None): + existing=await Manual_UPLOAD_CANDIDATE.get_by_id(session,form_row.manual_upload_candidate_id) + if existing: + if form_row.job_post_id and existing.job_post_id!=form_row.job_post_id: + existing.job_post_id=form_row.job_post_id + session.add(existing) + await session.commit() + return existing + + email=(form_row.candidate_email or "").strip().lower() + if not email: + raise HTTPException(status_code=422,detail="candidate_email is required to promote this form applicant") + if not form_row.job_post_id: + raise HTTPException(status_code=422,detail="job_post_id is required to promote this form applicant") + + existing=await Manual_UPLOAD_CANDIDATE.get_by_email_and_job( + session,email,form_row.job_post_id, + ) + if existing: + await FormData.link_manual_upload(session,form_row.id,existing.id) + return existing + + resume=(form_row.resume_link or "").strip() + file_name="" + if resume: + file_name=resume.rsplit("/",1)[-1][:180] or "resume" + + row=await Manual_UPLOAD_CANDIDATE.create_manual_upload_candidate(session,{ + "candidate_email":email, + "candidate_name":(form_row.name or "").strip() or email, + "candidate_phone":(form_row.candidate_number or "").strip(), + "job_post_id":str(form_row.job_post_id), + "current_company":(form_row.current_company or "").strip(), + "current_position":(form_row.position_applied_for or "").strip(), + "platform":"Form", + "apply_via":"form", + "experience":(form_row.experience or "").strip(), + "status":"PENDING", + "file_name":file_name, + "file_path":resume, + "full_text":"", + }) + await FormData.link_manual_upload(session,form_row.id,row.id) + try: + await HistoryRecorder(session).record( + HistoryEvent.CANDIDATE_CREATED.value, + actor_id=None,user_id=row.user_id, + manual_upload_candidate_id=row.id, + entity_type="manual_upload_candidate",entity_id=row.id, + to_value=row.candidate_email, + description="Form",commit=True, + ) + except Exception: + logger.exception("form promote history record failed for %s",form_row.id) + return row + + async def set_duplicate(self,record_id,is_duplicate): + if not isinstance(is_duplicate,bool): + raise HTTPException(status_code=422,detail="is_duplicate must be a boolean") + updated=await FormData.set_duplicate(self._require_session(),record_id,is_duplicate) + if not updated: + raise HTTPException(status_code=404,detail="Form data not found") + return await self.get_form_data_by_id(record_id) + + async def get_counts(self,sheet=None): + return await FormData.count_processing(self._require_session(),sheet=sheet) + async def get_imported_sheets(self): session=self._require_session() sheets=await FormData.get_sheet_names(session) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index b352a96..941abf2 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -27,7 +27,7 @@ logger = logging.getLogger("inbox.models") # Placeholder only. The account lands inactive and the candidate is mailed a # confirmation link; the real password comes from the reset flow afterwards. DEFAULT_CANDIDATE_PASSWORD = os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#") -CANDIDATE_ROLE_ID_FALLBACK = 8 # mirrors users/views.py:signup_user +CANDIDATE_ROLE_ID_FALLBACK = 4 # mirrors users/views.py:signup_user SKIP_SENDER_PREFIXES = ("noreply", "no-reply", "donotreply", "do-not-reply", "mailer-daemon", "postmaster", "bounce") diff --git a/backend/job/app.py b/backend/job/app.py index 3653429..65c6380 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -218,7 +218,7 @@ async def create_manual_candidate( @router.get("/candidate/fetch/users") async def fetch_users( - role_id:int=Query(8), + role_id:int=Query(4), top:int=Query(10), skip:int=Query(0), search:str=Query(None), diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 09a72e6..9e755c6 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, List, Optional from fastapi import HTTPException -from sqlalchemy import JSON, DateTime, Index, func, UniqueConstraint +from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, func, or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -78,6 +78,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): cls.current_company, cls.current_position, cls.experience, + cls.platform, + cls.apply_via, cls.created_at, cls.updated_at, AtsResults.id.label("ats_result_id"), @@ -135,6 +137,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): "current_company":row["current_company"] or None, "current_position":row["current_position"] or None, "experience":row["experience"] or None, + "platform":row["platform"] or None, + "apply_via":row["apply_via"] 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, @@ -192,7 +196,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): user=await Users.insert_user(session,{ "name":name, "email":email, - "role_id":role.id if role else 8, + "role_id":role.id if role else 4, "password":hash_password(default_pw), "is_active":True, "is_deleted":False, @@ -207,7 +211,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): linkedin_slug=primary_slug_from_text(fields.get("full_text") or ""), current_company=(fields.get("current_company") or "").strip(), current_position=(fields.get("current_position") or "").strip(), - apply_via="manual_upload", + apply_via=(fields.get("apply_via") or "manual_upload").strip() or "manual_upload", user_id=user.id, platform=(fields.get("platform") or "").strip(), created_by=cls._as_uuid(fields.get("created_by")), @@ -240,6 +244,70 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): result = await session.execute(select(cls).where(cls.id == uid)) return result.scalars().first() + @classmethod + async def get_by_email_and_job(cls, session: AsyncSession, email: str, job_post_id): + """Idempotency for form / re-import promotes against the same role.""" + cleaned = (email or "").strip().lower() + jid = cls._as_uuid(job_post_id) + if not cleaned or jid is None: + return None + result = await session.execute( + select(cls) + .where(cls.candidate_email == cleaned, cls.job_post_id == jid) + .order_by(cls.created_at.desc()) + ) + return result.scalars().first() + + @classmethod + async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None): + """Newest applications with a user + job for Talent Pool (manual / form).""" + from users.models import Users + + statement = ( + select(cls) + .join(Users, cls.user_id == Users.id) + .where(cls.user_id.is_not(None), cls.job_post_id.is_not(None)) + .order_by(cls.created_at.desc()) + ) + if search: + like = f"%{search.strip()}%" + statement = statement.where( + or_( + cls.candidate_name.ilike(like), + cls.candidate_email.ilike(like), + Users.name.ilike(like), + Users.email.ilike(like), + ) + ) + statement = statement.limit(limit).offset(offset) + result = await session.execute(statement) + return list(result.scalars().all()) + + @classmethod + async def sources_by_user_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]: + """Newest platform/apply_via label per user — Candidates Form badges.""" + parsed = [] + for raw in (user_ids or []): + uid = cls._as_uuid(raw) + if uid is not None: + parsed.append(uid) + if not parsed: + return {} + result = await session.execute( + select(cls.user_id, cls.platform, cls.apply_via, cls.created_at) + .where(cls.user_id.in_(parsed)) + .order_by(cls.created_at.desc()) + ) + out: dict[str, str] = {} + for user_id, platform, apply_via, _created in result.all(): + key = str(user_id) + if key in out: + continue + label = (platform or "").strip() or (apply_via or "").strip() + if label: + out[key] = label + return out + class Candidates(SQLModel, table=True): diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index eeb29d5..c5ecb49 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -555,6 +555,7 @@ class CandidateView: "current_company":(current_company or "").strip(), "current_position":(current_position or "").strip(), "platform":(platform or "").strip(), + "apply_via":"manual_upload", "experience":(experience or "").strip(), "status":(status or "").strip(), "referral_by":(referral_by or "").strip(), @@ -619,7 +620,51 @@ class CandidateView: if score.get("job_post_id"): payload["scored_job_post_id"]=score["job_post_id"] return payload - return await self.attach_job_posts(rows) + # List mode: inbox applications + manual/form applications (dedupe by user). + inbox_payloads=await self.attach_job_posts(rows) + if not isinstance(inbox_payloads,list): + inbox_payloads=[inbox_payloads] if inbox_payloads else [] + manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool( + self.session,limit=fetch_limit,offset=0,search=search, + ) + seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")} + manual_payloads=[] + for manual in manual_rows: + uid=str(manual.user_id) if manual.user_id else None + if uid and uid in seen: + continue + user=await Users.get_user_by_id(self.session,manual.user_id) if manual.user_id else None + job_post=None + if manual.job_post_id: + job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id)) + payload=serialize_manual_candidate_profile(manual,user,job_post) + # List shape matches attach_job_posts: keep job_posts, drop heavy detail. + manual_payloads.append({ + "inbox_id":None, + "manual_upload_candidate_id":payload["manual_upload_candidate_id"], + "user_id":payload["user_id"], + "candidate_id":None, + "name":payload["name"], + "email":payload["email"], + "is_active":payload.get("is_active"), + "message_id":None, + "created_at":payload.get("created_at"), + "application_status":payload.get("application_status"), + "experience":payload.get("experience"), + "current_employment":payload.get("current_employment"), + "current_title":payload.get("current_title"), + "resume_text":None, + "suggested_job_post_ids":[], + "assigned_job_post_id":payload.get("assigned_job_post_id"), + "job_posts":payload.get("job_posts") or [], + "assigned_job_post":payload.get("assigned_job_post"), + "source":payload.get("source"), + "ai_score":None, + "recommendation":None, + }) + if uid: + seen.add(uid) + return inbox_payloads+manual_payloads except HTTPException: raise except Exception as e: diff --git a/backend/users/views.py b/backend/users/views.py index 0a469ea..4df2e17 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -54,7 +54,7 @@ class User: if not fields.get("password"): raise HTTPException(status_code=400,detail="Password is required") role=await Roles.get_role_by_name(self.session,EnumRoles.CANDIDATE.value) - fields["role_id"]=role.id if role else 8 + fields["role_id"]=role.id if role else 4 user=await Users.insert_user(self.session,fields) # Signup lands inactive; the mailed link is what flips is_active. service=Confirmation(session=self.session) diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index b2a6ed0..328dd82 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -97,7 +97,7 @@ export function toCandidateView(row) { * Candidate USER accounts — `users` rows filtered by role, not the scored * `candidates` table. Needs candidates.view. * - * role_id 8 is the seeded `candidate` role (backend/role/models.py::EnumRoles); + * role_id 4 is the seeded `candidate` role (backend/role/models.py::EnumRoles); * the route defaults to it, and we send it explicitly so a re-seed that renumbers * the roles fails loudly here rather than silently listing the wrong people. * @@ -110,7 +110,7 @@ export function toCandidateView(row) { * layer (`get_users(role_id=, top=, skip=)`), so searching is a no-op * server-side. Filtering stays client-side until that is fixed. */ -export function listCandidateUsers({ roleId = 8, top = 500, skip = 0 } = {}) { +export function listCandidateUsers({ roleId = 4, top = 500, skip = 0 } = {}) { return request('/candidate/fetch/users', { params: { role_id: roleId, top, skip }, }) diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js index 4d30c80..e96a755 100644 --- a/frontend/src/api/pipeline.js +++ b/frontend/src/api/pipeline.js @@ -153,6 +153,7 @@ function sourceFields(row, kind) { jobTitle: row.title ?? null, currentTitle: row.current_position || null, currentCompany: row.current_company || null, + source: row.platform || row.apply_via || 'Manual', } } return { @@ -163,6 +164,7 @@ function sourceFields(row, kind) { jobTitle: row.title ?? null, currentTitle: row.current_title || null, currentCompany: row.current_employment || null, + source: null, } } diff --git a/frontend/src/api/sheet.js b/frontend/src/api/sheet.js index fb8708f..f40a05a 100644 --- a/frontend/src/api/sheet.js +++ b/frontend/src/api/sheet.js @@ -16,13 +16,21 @@ export function listFormDataSheets() { * Paginated form_data rows. * * `offset` / `limit` map 1:1 to the backend Query params (not skip/top). + * Optional `processing_state` / `is_duplicate` power the Sheet Forms tabs. */ -export function listFormData({ sheet, search, offset = 0, limit } = {}) { +export function listFormData({ + sheet, search, offset = 0, limit, processing_state, is_duplicate, +} = {}) { return request('/sheet/form-data/fetch', { - params: { sheet, search, offset, limit }, + params: { sheet, search, offset, limit, processing_state, is_duplicate }, }) } +/** Tab badge counts for one sheet (or all sheets when sheet omitted). */ +export function fetchFormCounts({ sheet } = {}) { + return request('/sheet/form-data/counts', { params: { sheet } }) +} + /** One form_data row by UUID. */ export function getFormData(recordId) { return request(`/sheet/form-data/${recordId}`) @@ -35,3 +43,18 @@ export function assignJobPost(recordId, jobPostId) { body: { job_post_id: jobPostId }, }) } + +/** unread | imported | processed | rejected — same allowlist as inbox. */ +export function setProcessingState(recordId, processingState) { + return request(`/sheet/form-data/${recordId}/processing-state`, { + method: 'PATCH', + body: { processing_state: processingState }, + }) +} + +export function setDuplicate(recordId, isDuplicate) { + return request(`/sheet/form-data/${recordId}/duplicate`, { + method: 'PATCH', + body: { is_duplicate: isDuplicate }, + }) +} diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 003fbe0..998a6e2 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -33,6 +33,7 @@ export const qk = { formSheets: () => ['mailbox', 'form-sheets'], formData: (p = {}) => ['mailbox', 'form-data', p], formRow: (id) => ['mailbox', 'form-row', id], + formCounts: (p = {}) => ['mailbox', 'form-counts', p], }, assessments: { all: () => ['assessments'], diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index d1aece4..de1904e 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -36,7 +36,7 @@ const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer /** The seeded `candidate` role (backend/role/models.py::EnumRoles). */ const CANDIDATE_ROLE_ID = 8 -/* Rows are candidate USER accounts (GET /candidate/fetch/users?role_id=8), not +/* Rows are candidate USER accounts (GET /candidate/fetch/users?role_id=4), not rows of the scored `candidates` table. Why: /candidate/scored/fetch only ever returns CVs that have been through the @@ -48,9 +48,22 @@ const CANDIDATE_ROLE_ID = 8 toCandidateUserView. Open a candidate to get their score, which the shared Talent Pool profile modal (CandidateProfile.jsx) reads live by userId. */ async function fetchCandidates() { - const res = await candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID }) - const rows = Array.isArray(res?.data) ? res.data : [] - return rows.map(candidatesApi.toCandidateUserView) + const [usersRes, appsRes] = await Promise.all([ + candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID }), + candidatesApi.list({ limit: 500 }).catch(() => null), + ]) + const rows = Array.isArray(usersRes?.data) ? usersRes.data : [] + const sourceByUser = new Map() + for (const app of Array.isArray(appsRes?.data) ? appsRes.data : []) { + const uid = app.user_id + if (!uid || sourceByUser.has(uid)) continue + if (app.source) sourceByUser.set(String(uid), app.source) + } + return rows.map((row) => { + const view = candidatesApi.toCandidateUserView(row) + const source = sourceByUser.get(String(view.userId)) + return source ? { ...view, source } : view + }) } async function fetchJobs() { @@ -386,7 +399,12 @@ export default function Candidates() {