From 4b93246d43fae87596b2f5ecaf1d0b5218c0472f Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Mon, 31 Aug 2026 14:34:23 +0500 Subject: [PATCH] Find Talent: shortlist sourced profiles for outreach Manual outreach funnel on talent_profiles (sourced -> shortlisted -> contacted, one-step undo) tracked per profile with who/when stamps. New PATCH /talent/profiles/outreach behind talent.edit; Find Talent screen gains All/Shortlisted/Contacted tabs, a star to shortlist and a check to record that the recruiter messaged the person on LinkedIn (the app itself sends nothing). Re-runs never reset outreach state. Co-Authored-By: Claude Fable 5 --- .../migrations/manual/017_talent_outreach.sql | 21 ++ backend/talent/app.py | 23 ++ backend/talent/enums.py | 58 +++++ backend/talent/models.py | 49 ++++- backend/talent/serializers.py | 16 +- backend/talent/views.py | 35 ++- frontend/src/api/talent.js | 19 ++ frontend/src/screens/Talent.jsx | 200 ++++++++++++++++-- 8 files changed, 393 insertions(+), 28 deletions(-) create mode 100644 backend/migrations/manual/017_talent_outreach.sql create mode 100644 backend/talent/enums.py diff --git a/backend/migrations/manual/017_talent_outreach.sql b/backend/migrations/manual/017_talent_outreach.sql new file mode 100644 index 0000000..32b2e9f --- /dev/null +++ b/backend/migrations/manual/017_talent_outreach.sql @@ -0,0 +1,21 @@ +-- 017_talent_outreach.sql +-- Manual outreach funnel on talent_profiles: sourced -> shortlisted -> contacted. +-- The app sends no messages; recruiters reach out on LinkedIn themselves and +-- record the result here (who shortlisted / contacted, and when). Applied at +-- startup by alembic_setup.run_manual_sql(). Needed because prod boots with +-- DB_AUTOGENERATE=false. + +ALTER TABLE app.talent_profiles + ADD COLUMN IF NOT EXISTS outreach_status TEXT NOT NULL DEFAULT 'sourced'; + +ALTER TABLE app.talent_profiles + ADD COLUMN IF NOT EXISTS shortlisted_at TIMESTAMPTZ; + +ALTER TABLE app.talent_profiles + ADD COLUMN IF NOT EXISTS shortlisted_by UUID REFERENCES app.users(id); + +ALTER TABLE app.talent_profiles + ADD COLUMN IF NOT EXISTS contacted_at TIMESTAMPTZ; + +ALTER TABLE app.talent_profiles + ADD COLUMN IF NOT EXISTS contacted_by UUID REFERENCES app.users(id); diff --git a/backend/talent/app.py b/backend/talent/app.py index 9479eaa..60c0483 100644 --- a/backend/talent/app.py +++ b/backend/talent/app.py @@ -16,6 +16,10 @@ class TalentRunStart(BaseModel): keywords: str | None = None +class OutreachStatusUpdate(BaseModel): + outreach_status: str + + @router.post("/talent/runs/start") async def start_talent_run( payload: TalentRunStart, @@ -102,6 +106,25 @@ async def fetch_talent_profile( raise HTTPException(status_code=500, detail=str(e)) +@router.patch("/talent/profiles/outreach") +async def set_talent_outreach_status( + payload: OutreachStatusUpdate, + profile_id: str = Query(...), + current_user: dict = Depends(require_permission(PermissionTag.TALENT_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service = Talent(session=session) + data = await service.set_outreach_status( + profile_id, 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)) + + @router.delete("/talent/profiles/delete") async def delete_talent_profile( profile_id: str = Query(...), diff --git a/backend/talent/enums.py b/backend/talent/enums.py new file mode 100644 index 0000000..26092d5 --- /dev/null +++ b/backend/talent/enums.py @@ -0,0 +1,58 @@ +from enum import Enum + + +class OutreachStatus(str, Enum): + """Manual outreach funnel on talent_profiles.outreach_status. + + The app never sends messages: a recruiter shortlists a sourced profile, + reaches out on LinkedIn themselves, then marks the profile contacted. + Values are the wire form the Find Talent screen PATCHes; labels are what + the tabs render. + """ + + SOURCED = "sourced" + SHORTLISTED = "shortlisted" + CONTACTED = "contacted" + + @property + def label(self) -> str: + return _LABELS[self] + + @classmethod + def parse(cls, value): + """Accept the stored value or the UI label. None if neither matches.""" + raw = (value or "").strip() + if not raw: + return None + lowered = raw.lower() + for member in cls: + if raw == member.value or lowered == member.value or raw == member.label: + return member + return None + + @classmethod + def values(cls) -> tuple[str, ...]: + return tuple(m.value for m in cls) + + @classmethod + def as_list(cls) -> list[dict]: + return [{"value": m.value, "label": m.label} for m in cls] + + +_LABELS = { + OutreachStatus.SOURCED: "Sourced", + OutreachStatus.SHORTLISTED: "Shortlisted", + OutreachStatus.CONTACTED: "Contacted", +} + +# One-way funnel with single-step undo. sourced -> contacted is disallowed so +# every contacted row carries shortlist stamps, and contacted -> sourced is +# disallowed so un-shortlisting a contacted profile takes two deliberate steps. +ALLOWED_OUTREACH_TRANSITIONS = { + OutreachStatus.SOURCED.value: {OutreachStatus.SHORTLISTED.value}, + OutreachStatus.SHORTLISTED.value: { + OutreachStatus.SOURCED.value, + OutreachStatus.CONTACTED.value, + }, + OutreachStatus.CONTACTED.value: {OutreachStatus.SHORTLISTED.value}, +} diff --git a/backend/talent/models.py b/backend/talent/models.py index b267756..47a71b9 100644 --- a/backend/talent/models.py +++ b/backend/talent/models.py @@ -13,6 +13,8 @@ from sqlalchemy import DateTime, JSON, UniqueConstraint, func from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, SQLModel, select +from talent.enums import ALLOWED_OUTREACH_TRANSITIONS, OutreachStatus + def _now() -> datetime: return datetime.now(timezone.utc) @@ -24,8 +26,9 @@ TERMINAL_RUN_STATUSES = ("succeeded", "failed", "timed_out", "aborted") # Profile fields refreshed when a later run re-finds the same person. Kept at # module level: an underscore-prefixed class attribute on a SQLModel becomes a -# Pydantic ModelPrivateAttr, which is not iterable. `is_deleted` is deliberately -# absent — a dismissed profile stays dismissed. +# Pydantic ModelPrivateAttr, which is not iterable. `is_deleted` and the +# outreach_* columns are deliberately absent — a dismissed profile stays +# dismissed, and a re-run must not reset a recruiter's shortlist/contact state. MUTABLE_PROFILE_FIELDS = ( "public_id", "full_name", "headline", "location", "current_title", "current_company", "avatar_url", "summary", "skills", @@ -197,6 +200,14 @@ class TalentProfiles(SQLModel, table=True): skills: list = Field(default_factory=list, sa_type=JSON) match_score: int | None = Field(default=None) raw: dict = Field(default_factory=dict, sa_type=JSON) + # Manual outreach funnel (OutreachStatus): sourced -> shortlisted -> + # contacted. server_default is load-bearing: the column arrives as an ALTER + # on a populated table (017_talent_outreach.sql). + outreach_status: str = Field(default="sourced", sa_column_kwargs={"server_default": "sourced"}) + shortlisted_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + shortlisted_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + contacted_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + contacted_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") first_seen_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) last_seen_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) @@ -282,6 +293,40 @@ class TalentProfiles(SQLModel, table=True): statement = select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712 return (await session.execute(statement)).scalars().first() + @classmethod + async def set_outreach_status(cls, session: AsyncSession, record_id, status: str, *, actor=None): + """Idempotent outreach-funnel writer. Raises ValueError on a disallowed + transition; the stamp pair for a stage is set on entry and cleared on + undo, so shortlist stamps survive contacted and its undo. + """ + row = await cls.get_profile_by_id(session, record_id) + if not row: + return None + previous = row.outreach_status + if previous == status: + return row + if status not in ALLOWED_OUTREACH_TRANSITIONS.get(previous, set()): + raise ValueError(f"cannot move a {previous} profile to {status}") + actor_id = TalentRuns._as_uuid(actor) + if status == OutreachStatus.SHORTLISTED.value: + if previous == OutreachStatus.CONTACTED.value: + row.contacted_at = None + row.contacted_by = None + else: + row.shortlisted_at = _now() + row.shortlisted_by = actor_id + elif status == OutreachStatus.CONTACTED.value: + row.contacted_at = _now() + row.contacted_by = actor_id + else: # back to sourced + row.shortlisted_at = None + row.shortlisted_by = None + row.outreach_status = status + row.updated_at = _now() + session.add(row) + await session.commit() + return row + @classmethod async def soft_delete_profile(cls, session: AsyncSession, record_id): row = await cls.get_profile_by_id(session, record_id) diff --git a/backend/talent/serializers.py b/backend/talent/serializers.py index fef0181..fcda0e3 100644 --- a/backend/talent/serializers.py +++ b/backend/talent/serializers.py @@ -18,9 +18,12 @@ def serialize_talent_run(row) -> dict: } -def serialize_talent_profile(row) -> dict: +def serialize_talent_profile(row, *, user_names=None) -> dict: # `raw` stays server-side: it is an actor-shaped blob that can be large and # is only needed for debugging/re-mapping, not for the profile cards. + # `user_names` maps str(user_id) -> name for the shortlisted_by/contacted_by + # stamps (resolved by the caller in one query, Users.names_by_ids). + names = user_names or {} return { "id": str(row.id) if row.id else None, "job_post_id": str(row.job_post_id) if row.job_post_id else None, @@ -35,16 +38,23 @@ def serialize_talent_profile(row) -> dict: "summary": row.summary, "skills": row.skills or [], "match_score": row.match_score, + "outreach_status": row.outreach_status, + "shortlisted_at": row.shortlisted_at.isoformat() if row.shortlisted_at else None, + "shortlisted_by": str(row.shortlisted_by) if row.shortlisted_by else None, + "shortlisted_by_name": names.get(str(row.shortlisted_by)) if row.shortlisted_by else None, + "contacted_at": row.contacted_at.isoformat() if row.contacted_at else None, + "contacted_by": str(row.contacted_by) if row.contacted_by else None, + "contacted_by_name": names.get(str(row.contacted_by)) if row.contacted_by else None, "first_seen_at": row.first_seen_at.isoformat() if row.first_seen_at else None, "last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None, } -def serialize_talent_profile_detail(row) -> dict: +def serialize_talent_profile_detail(row, *, user_names=None) -> dict: # The card payload plus employment/education history unpacked from the raw # actor item. Detail is fetched one profile at a time, so the extra weight # never rides along with the list endpoint. - data = serialize_talent_profile(row) + data = serialize_talent_profile(row, user_names=user_names) data["experience"] = extract_experience(row.raw or {}) data["education"] = extract_education(row.raw or {}) return data diff --git a/backend/talent/views.py b/backend/talent/views.py index c17d30b..b725edc 100644 --- a/backend/talent/views.py +++ b/backend/talent/views.py @@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from job.job_post.models import JobPosts from talent import plugins +from talent.enums import OutreachStatus from talent.matching import annotate_applications from talent.models import TalentProfiles, TalentRuns from talent.serializers import ( @@ -11,6 +12,7 @@ from talent.serializers import ( serialize_talent_profile_detail, serialize_talent_run, ) +from users.models import Users def _search_basis(actor_input: dict) -> dict: @@ -201,12 +203,19 @@ class Talent: rows, total = await TalentRuns.fetch_runs(self.session, job_post_id=job_post_id) return [serialize_talent_run(r) for r in rows], total + async def _outreach_actor_names(self, rows) -> dict: + return await Users.names_by_ids( + self.session, + [r.shortlisted_by for r in rows] + [r.contacted_by for r in rows], + ) + async def fetch_profiles(self, job_post_id, search=None, top=None, skip=0): await self._get_job(job_post_id) rows, total = await TalentProfiles.fetch_profiles( self.session, job_post_id=job_post_id, search=search, top=top, skip=skip ) - profiles = [serialize_talent_profile(r) for r in rows] + names = await self._outreach_actor_names(rows) + profiles = [serialize_talent_profile(r, user_names=names) for r in rows] profiles = await annotate_applications(self.session, profiles) return profiles, total @@ -214,10 +223,32 @@ class Talent: row = await TalentProfiles.get_profile_by_id(self.session, profile_id) if not row: raise HTTPException(status_code=404, detail="Talent profile not found") - data = serialize_talent_profile_detail(row) + names = await self._outreach_actor_names([row]) + data = serialize_talent_profile_detail(row, user_names=names) await annotate_applications(self.session, [data]) return data + async def set_outreach_status(self, profile_id, payload, current_user): + parsed = OutreachStatus.parse(payload.get("outreach_status") or "") + if parsed is None: + raise HTTPException( + status_code=422, + detail=f"outreach_status must be one of {', '.join(OutreachStatus.values())}", + ) + try: + row = await TalentProfiles.set_outreach_status( + self.session, + profile_id, + parsed.value, + actor=(current_user or {}).get("id"), + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + if not row: + raise HTTPException(status_code=404, detail="Talent profile not found") + names = await self._outreach_actor_names([row]) + return serialize_talent_profile(row, user_names=names) + async def delete_profile(self, profile_id): row = await TalentProfiles.soft_delete_profile(self.session, profile_id) if not row: diff --git a/frontend/src/api/talent.js b/frontend/src/api/talent.js index 6081d7f..074a72f 100644 --- a/frontend/src/api/talent.js +++ b/frontend/src/api/talent.js @@ -47,6 +47,20 @@ export function getProfile(profileId) { return request('/talent/profiles/fetch_by_id', { params: { profile_id: profileId } }) } +/** + * Move a profile along the manual outreach funnel. Needs talent.edit. + * Allowed: sourced->shortlisted, shortlisted->sourced (un-shortlist), + * shortlisted->contacted, contacted->shortlisted (undo). The app sends no + * messages — "contacted" records that the recruiter reached out on LinkedIn. + */ +export function setOutreachStatus(profileId, outreachStatus) { + return request('/talent/profiles/outreach', { + method: 'PATCH', + params: { profile_id: profileId }, + body: { outreach_status: outreachStatus }, + }) +} + /** Dismiss a profile (soft delete; re-runs will not resurrect it). Needs talent.delete. */ export function deleteProfile(profileId) { return request('/talent/profiles/delete', { @@ -89,6 +103,11 @@ export function toProfileView(row) { summary: row.summary ?? null, skills: Array.isArray(row.skills) ? row.skills : [], matchScore: row.match_score ?? null, + outreachStatus: row.outreach_status ?? 'sourced', + shortlistedAt: row.shortlisted_at ? new Date(row.shortlisted_at) : null, + shortlistedByName: row.shortlisted_by_name ?? null, + contactedAt: row.contacted_at ? new Date(row.contacted_at) : null, + contactedByName: row.contacted_by_name ?? null, lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at) : null, // Non-null when a CV in the ATS carries this profile's /in/ link: // { source, status, job_post_id, candidate, applied_at, same_job, applications } diff --git a/frontend/src/screens/Talent.jsx b/frontend/src/screens/Talent.jsx index e383197..3c78622 100644 --- a/frontend/src/screens/Talent.jsx +++ b/frontend/src/screens/Talent.jsx @@ -14,7 +14,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import PageHeader from '../ui/PageHeader' import { Badge, EmptyState, Icon } from '../ui/primitives' +import { Tabs } from '../ui/Tabs' import { useToast } from '../ui/Toast' +import { useAuth } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' @@ -30,6 +32,15 @@ const RUN_BADGE = { aborted: ['b-amber', 'Aborted'], } +/* Manual outreach funnel: sourced -> shortlisted -> contacted, one-step undo. + The app sends nothing — "contacted" records that the recruiter messaged the + person on LinkedIn themselves. Mirror of backend/talent/enums.py. */ +const OUTREACH_TOAST = { + shortlisted: 'Added to shortlist', + contacted: 'Marked as contacted', + sourced: 'Removed from shortlist', +} + async function fetchJobs() { const res = await candidatesApi.listJobs() const rows = Array.isArray(res?.data) ? res.data : [] @@ -155,7 +166,33 @@ function AppliedBadge({ applied }) { ) } -function ProfileCard({ p, onView, onDismiss, dismissing }) { +/** Card/modal-shared outreach controls: star = shortlist toggle, check = contacted. */ +function outreachProps(p) { + const s = p.outreachStatus + return { + star: { + shown: true, + active: s !== 'sourced', + disabled: s === 'contacted', + next: s === 'sourced' ? 'shortlisted' : 'sourced', + tip: + s === 'sourced' ? 'Shortlist for outreach' + : s === 'shortlisted' ? 'Shortlisted — click to remove' + : 'Undo Contacted first to un-shortlist', + }, + check: { + shown: s !== 'sourced', + active: s === 'contacted', + next: s === 'shortlisted' ? 'contacted' : 'shortlisted', + tip: + s === 'shortlisted' + ? 'Mark contacted (after messaging on LinkedIn)' + : `Contacted ${p.contactedAt ? fmtDate(p.contactedAt) : ''}${p.contactedByName ? ` by ${p.contactedByName}` : ''} — click to undo`, + }, + } +} + +function ProfileCard({ p, onView, onDismiss, dismissing, canEdit, onOutreach, outreachBusy, showContacted }) { const crit = p.summary || p.headline || '' const shown = p.skills.slice(0, 5) const more = p.skills.length - shown.length @@ -203,9 +240,38 @@ function ProfileCard({ p, onView, onDismiss, dismissing }) { > + {canEdit && (() => { + const o = outreachProps(p) + return ( + <> + + {o.check.shown && showContacted && ( + + )} + + ) + })()} + {o.check.shown && ( + + )} + + )} {p && ( Open LinkedIn @@ -262,6 +353,18 @@ function TalentProfileDetail({ profileId, onClose }) { {p.location && {p.location}} LinkedIn + {p.shortlistedAt && ( + + Shortlisted {fmtDate(p.shortlistedAt)} + {p.shortlistedByName ? ` by ${p.shortlistedByName}` : ''} + + )} + {p.contactedAt && ( + + Contacted {fmtDate(p.contactedAt)} + {p.contactedByName ? ` by ${p.contactedByName}` : ''} + + )} {p.lastSeenAt && ( Found {fmtDate(p.lastSeenAt)} )} @@ -331,8 +434,11 @@ function TalentProfileDetail({ profileId, onClose }) { export default function Talent() { const { toast } = useToast() const qc = useQueryClient() + const { can } = useAuth() + const canEdit = can('talent.edit') const [jobId, setJobId] = useState('') + const [tab, setTab] = useState('all') const [activeRunId, setActiveRunId] = useState(null) const [confirmOpen, setConfirmOpen] = useState(false) const [search, setSearch] = useState('') @@ -399,14 +505,25 @@ export default function Talent() { : [], [profilesQuery.data], ) + const counts = useMemo(() => ({ + all: profiles.length, + shortlisted: profiles.filter((p) => p.outreachStatus === 'shortlisted').length, + contacted: profiles.filter((p) => p.outreachStatus === 'contacted').length, + }), [profiles]) + const tabs = [ + { key: 'all', label: 'All', count: counts.all }, + { key: 'shortlisted', label: 'Shortlisted', count: counts.shortlisted }, + { key: 'contacted', label: 'Contacted', count: counts.contacted }, + ] const visible = useMemo(() => { + const scoped = tab === 'all' ? profiles : profiles.filter((p) => p.outreachStatus === tab) const q = search.trim().toLowerCase() - if (!q) return profiles - return profiles.filter((p) => + if (!q) return scoped + return scoped.filter((p) => [p.name, p.headline, p.currentCompany, p.currentTitle, p.location] .some((f) => f && f.toLowerCase().includes(q)), ) - }, [profiles, search]) + }, [profiles, search, tab]) const effectiveLocation = locationChoice === CUSTOM_LOCATION ? customLocation.trim() : locationChoice @@ -439,6 +556,17 @@ export default function Talent() { onError: (err) => toast(friendlyAuthError(err, 'Could not dismiss the profile'), 'error'), }) + const outreach = useMutation({ + mutationFn: ({ id, status }) => talentApi.setOutreachStatus(id, status), + onSuccess: (_res, vars) => { + qc.invalidateQueries({ queryKey: qk.talent.profiles({ jobId }) }) + qc.invalidateQueries({ queryKey: qk.talent.profile(vars.id) }) + toast(OUTREACH_TOAST[vars.status] ?? 'Updated', 'success') + }, + onError: (err) => toast(friendlyAuthError(err, 'Could not update outreach status'), 'error'), + }) + const handleOutreach = (profile, status) => outreach.mutate({ id: profile.id, status }) + const statusRun = runInFlight || !latestRun ? activeRun : latestRun const [badgeCls, badgeLabel] = statusRun ? (RUN_BADGE[statusRun.status] ?? ['b-gray', statusRun.status]) : [] @@ -461,6 +589,7 @@ export default function Talent() { onChange={(e) => { const nextId = e.target.value setJobId(nextId) + setTab('all') setActiveRunId(null) setSearch('') setVisibleCount(10) @@ -542,7 +671,13 @@ export default function Talent() { ) ) : ( <> -
+ { setTab(t); setVisibleCount(10) }} + tabs={tabs} + /> +
-
- {visible.slice(0, visibleCount).map((p) => ( - setViewProfileId(profile.id)} - onDismiss={(profile) => dismissing.mutate(profile)} - dismissing={dismissing.isPending} - /> - ))} -
+ {visible.length === 0 && tab !== 'all' && !search.trim() ? ( + tab === 'shortlisted' ? ( + + Star a profile in the All tab to build your outreach list. + + ) : ( + + After messaging a shortlisted person on LinkedIn, mark them contacted + so the team knows they have been reached. + + ) + ) : ( +
+ {visible.slice(0, visibleCount).map((p) => ( + setViewProfileId(profile.id)} + onDismiss={(profile) => dismissing.mutate(profile)} + dismissing={dismissing.isPending} + canEdit={canEdit} + onOutreach={handleOutreach} + outreachBusy={outreach.isPending} + showContacted={tab !== 'all'} + /> + ))} +
+ )}
{visible.length > visibleCount ? ( - ) : ( + ) : tab === 'all' ? ( - )} + ) : null}
)} {viewProfileId && ( - setViewProfileId(null)} /> + setViewProfileId(null)} + canEdit={canEdit} + onOutreach={handleOutreach} + outreachBusy={outreach.isPending} + /> )} {confirmOpen && (