diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index 83dffa1..a16fd06 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -456,6 +456,65 @@ class FormData(SQLModel, table=True): }) return rows + @classmethod + async def list_for_offer_picker(cls, session: AsyncSession, *, job_post_ids=None, search=None): + """Unpromoted assigned sheet applicants for the offer dropdown.""" + from job.job_post.models import JobPosts + + assigned = func.coalesce(cls.assigned_job_post_id, cls.job_post_id) + qry = ( + select(cls, JobPosts.title) + .outerjoin(JobPosts, assigned == JobPosts.id) + .where(assigned.is_not(None)) + .where(cls.manual_upload_candidate_id.is_(None)) + .where(cls.is_duplicate == False) # noqa: E712 + .where(cls.processing_state != "rejected") + .order_by(cls.created_at.desc(), cls.id.desc()) + ) + if job_post_ids is not None: + ids = list(job_post_ids) + if not ids: + return [] + qry = qry.where(assigned.in_(ids)) + if search: + pattern = f"%{search.strip()}%" + qry = qry.where(or_(cls.name.ilike(pattern), cls.candidate_email.ilike(pattern))) + result = await session.execute(qry) + rows = [] + for rec, title in result.all(): + job_id = rec.assigned_job_post_id or rec.job_post_id + rows.append({ + "form_data_id": str(rec.id), + "user_id": None, + "name": (rec.name or "").strip() or None, + "email": (rec.candidate_email or "").strip().lower() or None, + "job_post_id": str(job_id) if job_id else None, + "job_title": title or rec.position_applied_for or None, + "application_status": rec.processing_state or "PENDING", + }) + return rows + + @classmethod + async def form_ids_by_manual_ids(cls, session: AsyncSession, manual_ids): + """form_data.id keyed by the promoted manual_upload_candidate_id.""" + uids = [] + for raw in manual_ids or []: + try: + uids.append(uuid.UUID(str(raw))) + except (TypeError, ValueError): + continue + if not uids: + return {} + result = await session.execute( + select(cls.manual_upload_candidate_id, cls.id) + .where(cls.manual_upload_candidate_id.in_(uids)) + ) + out = {} + for manual_id, form_id in result.all(): + if manual_id and form_id: + out[str(manual_id)] = str(form_id) + return out + @classmethod async def job_post_ids_by_emails(cls, session: AsyncSession, emails): """(email, job_post_id) pairs from assigned or job_post_id. Unlinked skipped.""" diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 91101f3..2c974a9 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -87,6 +87,7 @@ class Inbox(SQLModel, table=True): Inbox_Messages.candidate_phone_number.label("phone"), Inbox_Messages.assigned_job_post_id, Inbox_Messages.application_status, + Inbox_Messages.is_duplicate, Inbox_Messages.current_employment, Inbox_Messages.current_title, Inbox_Messages.experience, @@ -147,6 +148,7 @@ class Inbox(SQLModel, table=True): "email":row["email"], "linkedin_url":row["linkedin_url"] or None, "application_status":status.value if status else None, + "is_duplicate": bool(row["is_duplicate"]), "phone":row["phone"], "assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None, "title":row["title"] or None, diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 2c67ea6..05a8875 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -89,6 +89,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): Users.email, cls.job_post_id, Users.name, + cls.candidate_name, cls.candidate_phone, JobPosts.title, cls.status, @@ -154,7 +155,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): "user_id":str(row["user_id"]) if row["user_id"] else None, "email":row["email"], "job_post_id":str(row["job_post_id"]) if row["job_post_id"] else None, - "name":row["name"], + "name": (row["name"] or "").strip() or (row["candidate_name"] or "").strip() or None, "candidate_phone":row["candidate_phone"], "title":row["title"] or None, "application_status":row["status"] or None, diff --git a/backend/job/history/enums.py b/backend/job/history/enums.py index 8d955d6..9a1a30d 100644 --- a/backend/job/history/enums.py +++ b/backend/job/history/enums.py @@ -20,3 +20,4 @@ class HistoryEvent(str, Enum): ATS_SCORED = "ats.scored" FORM_CREATED = "form.created" FORM_UPDATED = "form.updated" + OFFER_SENT = "offer.sent" diff --git a/backend/migrations/manual/034_offers_sent_identity.sql b/backend/migrations/manual/034_offers_sent_identity.sql new file mode 100644 index 0000000..5964bc1 --- /dev/null +++ b/backend/migrations/manual/034_offers_sent_identity.sql @@ -0,0 +1,25 @@ +-- 034_offers_sent_identity.sql +-- Offers may target inbox, manual-upload, or sheet FormData applications. +-- inbox_id was NOT NULL, which blocked non-email candidates. Applied at +-- startup by alembic_setup.run_manual_sql(). + +ALTER TABLE app.offers + ALTER COLUMN inbox_id DROP NOT NULL; + +ALTER TABLE app.offers + ADD COLUMN IF NOT EXISTS manual_upload_candidate_id UUID REFERENCES app.manual_upload_candidate (id); + +ALTER TABLE app.offers + ADD COLUMN IF NOT EXISTS form_data_id UUID REFERENCES app.form_data (id); + +ALTER TABLE app.offers + ADD COLUMN IF NOT EXISTS equity_units INTEGER; + +ALTER TABLE app.offers + ADD COLUMN IF NOT EXISTS equity_instrument VARCHAR; + +CREATE INDEX IF NOT EXISTS ix_offers_manual_upload_candidate_id + ON app.offers (manual_upload_candidate_id); + +CREATE INDEX IF NOT EXISTS ix_offers_form_data_id + ON app.offers (form_data_id); diff --git a/backend/notifications/views.py b/backend/notifications/views.py index ad0ee4a..58934b5 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -42,6 +42,7 @@ _HISTORY_KIND = { "ats.scored": "assessment", "form.created": "approval", "form.updated": "approval", + "offer.sent": "application", "note.created": "message", "note.updated": "message", "feedback.created": "message", @@ -66,6 +67,7 @@ _HISTORY_TITLE = { "ats.scored": "ATS score ready", "form.created": "Form submitted", "form.updated": "Form updated", + "offer.sent": "Offer sent", } _HISTORY_TAB = { "stage.changed": "History", @@ -80,6 +82,7 @@ _HISTORY_TAB = { "feedback.updated": "Activity", "form.created": "Forms", "form.updated": "Forms", + "offer.sent": "History", "ats.scored": "Resume", "document.uploaded": "History", "candidate.created": "History", diff --git a/backend/offer/app.py b/backend/offer/app.py index d4132d9..7444b2b 100644 --- a/backend/offer/app.py +++ b/backend/offer/app.py @@ -69,19 +69,39 @@ class OfferIssue(BaseModel): change_reason: str | None = None +class OfferSent(BaseModel): + offer_id: str | None = None + inbox_id: int | None = None + manual_upload_candidate_id: str | None = None + form_data_id: str | None = None + job_post_id: str + candidate_user_id: str | None = None + base_salary: float + currency: str | None = "USD" + salary_period: str | None = "year" + signing_bonus: float | None = None + annual_bonus_pct: float | None = None + equity_units: int | None = None + equity_instrument: str | None = None + start_date: datetime | None = None + expiry_date: datetime | None = None + change_reason: str | None = None + + @router.get("/offers/fetch") async def fetch_offers( current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)), offer_id: str | None = Query(None), status: str | None = Query(None), inbox_id: int | None = Query(None), + job_post_id: str | None = Query(None), top: int | None = Query(None), skip: int = Query(0,ge=0), session: AsyncSession = Depends(get_session), ): try: service=Offer(session=session) - data,total=await service.get_offers(offer_id,status,inbox_id,top,skip) + data,total=await service.get_offers(offer_id,status,inbox_id,job_post_id,top,skip) return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: raise @@ -137,3 +157,37 @@ async def issue_offer( raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/offers/jobs/candidates/lists") +async def list_offer_candidates( + current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)), + search: str | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0,ge=0), + session: AsyncSession = Depends(get_session), +): + try: + service=Offer(session=session) + data,total=await service.list_candidates(current_user,search,top,skip) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/offers/jobs/sent") +async def send_job_offer( + payload: OfferSent, + current_user: dict = Depends(require_permission(PermissionTag.OFFERS_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + service=Offer(session=session) + data=await service.send_offer(payload.model_dump(exclude_unset=True),current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/offer/models.py b/backend/offer/models.py index f83a0bf..604dd60 100644 --- a/backend/offer/models.py +++ b/backend/offer/models.py @@ -14,7 +14,11 @@ class Offers(SQLModel, table=True): __tablename__ = "offers" 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", + ) + form_data_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="form_data.id") job_post_id: uuid.UUID = Field(foreign_key="job_posts.id") candidate_user_id: uuid.UUID = Field(foreign_key="users.id") status: str = Field(default="draft") @@ -67,6 +71,7 @@ class Offers(SQLModel, table=True): *, status: str | None = None, inbox_id: int | None = None, + job_post_id=None, top: int | None = None, skip: int = 0, ): @@ -75,6 +80,9 @@ class Offers(SQLModel, table=True): statement = statement.where(cls.status == status) if inbox_id is not None: statement = statement.where(cls.inbox_id == int(inbox_id)) + jid = cls._as_uuid(job_post_id) if job_post_id is not None else None + if jid is not None: + statement = statement.where(cls.job_post_id == jid) count_statement = select(func.count()).select_from(statement.subquery()) total = (await session.execute(count_statement)).scalar_one() statement = statement.order_by(cls.created_at.desc()) @@ -147,6 +155,34 @@ class Offers(SQLModel, table=True): result = await session.execute(statement) return int(result.scalar_one() or 0) + @classmethod + async def get_open_for_candidate_job(cls, session: AsyncSession, candidate_user_id, job_post_id): + uid = cls._as_uuid(candidate_user_id) + jid = cls._as_uuid(job_post_id) + if uid is None or jid is None: + return None + result = await session.execute( + select(cls) + .where(cls.candidate_user_id == uid, cls.job_post_id == jid) + .where(cls.status.in_(("sent", "negotiating"))) + .order_by(cls.created_at.desc()) + ) + return result.scalars().first() + + @classmethod + async def get_failed_for_candidate_job(cls, session: AsyncSession, candidate_user_id, job_post_id): + uid = cls._as_uuid(candidate_user_id) + jid = cls._as_uuid(job_post_id) + if uid is None or jid is None: + return None + result = await session.execute( + select(cls) + .where(cls.candidate_user_id == uid, cls.job_post_id == jid) + .where(cls.status == "failed") + .order_by(cls.created_at.desc()) + ) + return result.scalars().first() + class OfferStatusHistory(SQLModel, table=True): __tablename__ = "offer_status_history" @@ -190,3 +226,5 @@ class OfferStatusHistory(SQLModel, table=True): return row import users.models as _users_models # noqa: E402, F401 +import job.candidate.models as _manual_models # noqa: E402, F401 +import g_sheet.models as _form_models # noqa: E402, F401 diff --git a/backend/offer/plugins.py b/backend/offer/plugins.py index 006bdb0..b962c02 100644 --- a/backend/offer/plugins.py +++ b/backend/offer/plugins.py @@ -1,6 +1,145 @@ +"""Offer helpers — compensation field list, offer-email template, Teams mail send. + +Pure module: no FastAPI imports and no HTTPException. + +`send_offer_mail` intentionally duplicates +`notifications.plugins.send_confirmation_mail` rather than importing it: that +helper is domain-named, and each domain owns its own mail copy and env reads. +""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone + +import httpx +from dotenv import load_dotenv + +from candidate_forms.plugins import FORM_READY_STATUSES + +load_dotenv() + +TEAMS_MAIL_API_URL = os.getenv("TEAMS_MAIL_API_URL") +TEAMS_API_TOKEN = os.getenv("TEAMS_API_TOKEN") +MAIL_ACCEPTED_STATUSES = {200, 202} + +INTERVIEW_PLUS = FORM_READY_STATUSES +OFFER_SUBJECT = "Offer from UtopiaBrands Recruitement team" + + def non_validation_values(): fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct", "equity_units","equity_instrument","start_date","expiry_date", "cadre","gross_salary_in_words","subsidized_services","probation_period", "notice_period","work_location","work_timings") - return fields \ No newline at end of file + return fields + + +def email_key(value) -> str: + return (value or "").strip().lower() + + +def stage_value(value) -> str: + if value is None: + return "" + return (value.value if hasattr(value,"value") else str(value)).strip().upper() + + +def is_interview_plus(value) -> bool: + return stage_value(value) in INTERVIEW_PLUS + + +def parse_offer_datetime(value): + if value in (None,""): + return None + if isinstance(value,datetime): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + text=str(value).strip() + if not text: + return None + if text.endswith("Z"): + text=text[:-1]+"+00:00" + try: + parsed=datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _period_label(period) -> str: + raw=(period or "year").strip().lower() + if raw in ("annual","year"): + return "per year" + if raw=="month": + return "per month" + if raw=="hour": + return "per hour" + return raw + + +def _money_line(amount, currency) -> str: + cur=(currency or "USD").strip() or "USD" + try: + n=float(amount) + pretty=f"{n:,.0f}" if n==int(n) else f"{n:,.2f}" + except (TypeError,ValueError): + pretty=str(amount) + return f"{cur} {pretty}" + + +def render_offer_email( + candidate_name, + base_salary, + currency="USD", + salary_period="year", + *, + annual_bonus_pct=None, + signing_bonus=None, + equity_units=None, + equity_instrument=None, +) -> tuple[str, str]: + name=(candidate_name or "").strip() or "Candidate" + salary=_money_line(base_salary,currency) + period=_period_label(salary_period) + html=( + f"
Dear {name},
" + "We are pleased to extend an offer of employment from UtopiaBrands.
" + f"Base salary: {salary} {period}.
" + ) + if annual_bonus_pct not in (None,""): + html+=f"Annual bonus: {annual_bonus_pct}%.
" + if signing_bonus not in (None,""): + html+=f"Signing bonus: {_money_line(signing_bonus,currency)}.
" + if equity_units not in (None,"",0): + instrument=(equity_instrument or "RSU").strip() or "RSU" + html+=f"Equity: {equity_units} {instrument}.
" + html+="Please reply to this email if you have questions about the offer.
" + return OFFER_SUBJECT,html + + +async def send_offer_mail(to_email: str, subject: str, html: str) -> None: + if not TEAMS_MAIL_API_URL or not TEAMS_API_TOKEN: + raise RuntimeError("TEAMS_MAIL_API_URL and TEAMS_API_TOKEN must be set") + fields = [ + ("subject", (None, subject)), + ("body", (None, html)), + ("content_type", (None, "html")), + ("save_to_sent_items", (None, "false")), + ("to", (None, to_email)), + ] + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post( + TEAMS_MAIL_API_URL, + files=fields, + headers={"Authorization": f"Bearer {TEAMS_API_TOKEN}"}, + ) + if response.status_code not in MAIL_ACCEPTED_STATUSES: + raise httpx.HTTPStatusError( + response.text, + request=response.request, + response=response, + ) diff --git a/backend/offer/serializers.py b/backend/offer/serializers.py index 165f0b4..a0733f8 100644 --- a/backend/offer/serializers.py +++ b/backend/offer/serializers.py @@ -1,9 +1,14 @@ -def serialize_offer(row) -> dict: +def serialize_offer(row, candidate_name=None, created_by_name=None) -> dict: return { "id": str(row.id) if row.id else None, "inbox_id": row.inbox_id, + "manual_upload_candidate_id": ( + str(row.manual_upload_candidate_id) if row.manual_upload_candidate_id else None + ), + "form_data_id": str(row.form_data_id) if row.form_data_id else None, "job_post_id": str(row.job_post_id) if row.job_post_id else None, "candidate_user_id": str(row.candidate_user_id) if row.candidate_user_id else None, + "candidate_name": candidate_name, "status": row.status, "base_salary": row.base_salary, "currency": row.currency, @@ -26,6 +31,7 @@ def serialize_offer(row) -> dict: "closed_at": row.closed_at.isoformat() if row.closed_at else None, "issued_by": str(row.issued_by) if row.issued_by else None, "created_by": str(row.created_by) if row.created_by else None, + "created_by_name": created_by_name, "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } @@ -43,3 +49,23 @@ def serialize_offer_history(row) -> dict: "actor_kind": row.actor_kind, "change_reason": row.change_reason, } + + +def serialize_offer_candidate(item) -> dict: + manual=item.get("manual_upload_candidate_id") + form_id=item.get("form_data_id") + user_id=item.get("user_id") + job_id=item.get("job_post_id") + return { + "inbox_id": item.get("inbox_id"), + "manual_upload_candidate_id": str(manual) if manual else None, + "form_data_id": str(form_id) if form_id else None, + "source": item.get("source"), + "user_id": str(user_id) if user_id else None, + "name": item.get("name"), + "email": item.get("email"), + "job_post_id": str(job_id) if job_id else None, + "job_title": item.get("job_title"), + "application_status": item.get("application_status"), + "stage": item.get("stage") or item.get("application_status"), + } diff --git a/backend/offer/views.py b/backend/offer/views.py index 7c08a76..bfea53d 100644 --- a/backend/offer/views.py +++ b/backend/offer/views.py @@ -1,12 +1,37 @@ import uuid from datetime import datetime,timezone +import httpx from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select +from g_sheet.models import FormData +from inbox.models import Inbox +from job.candidate.models import Manual_UPLOAD_CANDIDATE +from job.candidate.views import owned_job_ids_for_candidate_scope +from job.history.enums import HistoryEvent +from job.history.views import HistoryRecorder +from job.job_post.models import JobPosts +from job.pipeline.views import Pipeline +from notifications.models import Notifications from offer.models import Offers,OfferStatusHistory -from offer.serializers import serialize_offer -from offer.plugins import non_validation_values +from offer.plugins import ( + email_key, + is_interview_plus, + non_validation_values, + parse_offer_datetime, + render_offer_email, + send_offer_mail, + stage_value, +) +from offer.serializers import serialize_offer,serialize_offer_candidate +from users.models import Users +from users.permissions import sees_all_offers + +SOURCE_RANK={"inbox":0,"manual":1,"form":2} +MAIL_FAIL_DETAIL="Failed: the offer could not be sent" + def _as_uuid(value): if value in (None,""): @@ -26,24 +51,172 @@ def _user_id(current_user): return uid +def _comp_fields(payload): + fields={} + for key in non_validation_values(): + if key not in payload: + continue + value=payload[key] + if key in ("start_date","expiry_date"): + value=parse_offer_datetime(value) + fields[key]=value + return fields + + class Offer: def __init__(self,session:AsyncSession): self.session=session - async def get_offers(self,offer_id=None,status=None,inbox_id=None,top=None,skip=0): + async def _hydrate_offers(self,rows): + ids=[] + for row in rows: + ids.append(row.candidate_user_id) + ids.append(row.created_by) + names=await Users.names_by_ids(self.session,ids) + return [ + serialize_offer( + row, + candidate_name=names.get(str(row.candidate_user_id)), + created_by_name=names.get(str(row.created_by)), + ) + for row in rows + ] + + async def get_offers(self,offer_id=None,status=None,inbox_id=None,job_post_id=None,top=None,skip=0): if offer_id is not None: row=await Offers.get_offer_by_id(self.session,offer_id) if not row: raise HTTPException(status_code=404,detail="Offer not found") - return serialize_offer(row),1 + return (await self._hydrate_offers([row]))[0],1 rows,total=await Offers.fetch_offers( self.session, status=status, inbox_id=inbox_id, + job_post_id=job_post_id, top=top, skip=skip or 0, ) - return [serialize_offer(r) for r in rows],total + return await self._hydrate_offers(rows),total + + async def _offer_job_ids(self,current_user): + if sees_all_offers(current_user): + return None + return await owned_job_ids_for_candidate_scope(self.session,current_user) + + async def _assert_offer_job(self,current_user,job_post_id): + if sees_all_offers(current_user): + return + owned=await owned_job_ids_for_candidate_scope(self.session,current_user) + owned=set(owned or []) + jid=_as_uuid(job_post_id) + if jid is None or jid not in owned: + raise HTTPException(status_code=403,detail="This offer is outside your assigned jobs") + + async def list_candidates(self,current_user,search=None,top=None,skip=0): + owned=await self._offer_job_ids(current_user) + if owned is not None and not owned: + return [],0 + inbox_rows=await Inbox.get_all(self.session,job_post_ids=owned) + manual_rows=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_ids=owned) + form_rows=await FormData.list_for_offer_picker( + self.session,job_post_ids=owned,search=None, + ) + form_by_manual=await FormData.form_ids_by_manual_ids( + self.session,[r.get("id") for r in manual_rows], + ) + merged={} + for row in inbox_rows: + if row.get("is_duplicate"): + continue + if not is_interview_plus(row.get("application_status")): + continue + item={ + "inbox_id":row.get("inbox_id"), + "manual_upload_candidate_id":None, + "form_data_id":None, + "source":"inbox", + "user_id":row.get("user_id"), + "name":row.get("name"), + "email":email_key(row.get("email")), + "job_post_id":row.get("assigned_job_post_id"), + "job_title":row.get("title"), + "application_status":stage_value(row.get("application_status")), + } + self._merge_candidate(merged,item) + for row in manual_rows: + if not is_interview_plus(row.get("application_status")): + continue + mid=row.get("id") + item={ + "inbox_id":None, + "manual_upload_candidate_id":mid, + "form_data_id":form_by_manual.get(str(mid)) if mid else None, + "source":"manual", + "user_id":row.get("user_id"), + "name":row.get("name") or None, + "email":email_key(row.get("email") or row.get("candidate_email")), + "job_post_id":row.get("job_post_id"), + "job_title":row.get("title"), + "application_status":stage_value(row.get("application_status")), + } + self._merge_candidate(merged,item) + for row in form_rows: + item={ + "inbox_id":None, + "manual_upload_candidate_id":None, + "form_data_id":row.get("form_data_id"), + "source":"form", + "user_id":row.get("user_id"), + "name":row.get("name"), + "email":email_key(row.get("email")), + "job_post_id":row.get("job_post_id"), + "job_title":row.get("job_title"), + "application_status":stage_value(row.get("application_status")) or "PENDING", + } + self._merge_candidate(merged,item) + + items=list(merged.values()) + needle=(search or "").strip().lower() + if needle: + items=[ + r for r in items + if needle in (r.get("name") or "").lower() or needle in (r.get("email") or "") + ] + items.sort(key=lambda r:(r.get("name") or r.get("email") or "").lower()) + total=len(items) + start=int(skip or 0) + if start: + items=items[start:] + if top is not None: + items=items[:int(top)] + return [serialize_offer_candidate(r) for r in items],total + + def _merge_candidate(self,merged,item): + job_id=item.get("job_post_id") + if not job_id: + return + email=item.get("email") or "" + if email: + key=(email,str(job_id)) + else: + source=item.get("source") or "row" + raw=item.get("inbox_id") or item.get("manual_upload_candidate_id") or item.get("form_data_id") + key=(f"noid:{source}:{raw}",str(job_id)) + existing=merged.get(key) + if existing is None: + merged[key]=item + return + if SOURCE_RANK.get(item.get("source"),9){friendlyAuthError(historyQuery.error, 'History did not load.')}
) } - if ((historyQuery.isPending && !historyQuery.data) || (statusQuery.isPending && !statusQuery.data)) { + if ( + (historyQuery.isPending && !historyQuery.data) + || (statusQuery.isPending && !statusQuery.data) + || (offersQuery?.isPending && !offersQuery.data) + ) { returnLoading…
} @@ -1258,6 +1264,14 @@ function JobHistory({ historyQuery, statusQuery }) { at: toDate(row.created_at), row, })), + ...offerRows + .filter((row) => row.status === 'sent' || row.sent_at) + .map((row) => ({ + kind: 'offer', + id: `o-${row.id}`, + at: toDate(row.sent_at || row.created_at), + row, + })), ].sort((a, b) => (b.at?.getTime() || 0) - (a.at?.getTime() || 0)) if (events.length === 0) { @@ -1266,11 +1280,11 @@ function JobHistory({ historyQuery, statusQuery }) { return (+ {friendlyAuthError(offerQuery.error, 'Could not load this offer.')} +
+ )} +
Equity is stored as a unit count plus an instrument, so “20k RSU” is entered as 20000 and RSU.
- Issuing the offer is a separate, permissioned step (offers.approve).
+ Sending emails the candidate and moves the pipeline to Offer only after the mail succeeds.