From 4b93246d43fae87596b2f5ecaf1d0b5218c0472f Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Mon, 31 Aug 2026 14:34:23 +0500 Subject: [PATCH 1/4] 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 && ( From 629554e6f6cbbca2f21224df7f3bd1bb9ccd222e Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Mon, 31 Aug 2026 14:39:48 +0500 Subject: [PATCH 2/4] Inbox: do not print raw To-addresses in the source chip When no job board matches, the chip now says Email instead of the full address; the address stays in the hover title and the opened message. Board/referral chips are unchanged. Co-Authored-By: Claude Fable 5 --- frontend/src/screens/Inbox.jsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 79c7883..4d14d64 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -225,10 +225,15 @@ async function fetchFormDetail(recordId) { function SourceChip({ item }) { // The dot carries the partner's brand colour; the label uses theme text — // 11px labels in the partner colour failed AA in both themes. + // When no board matched, `source` is the raw To-address; keep it out of the + // chip — show "Email", and leave the address to the tooltip and the opened + // message. + const source = String(item.source ?? '') + const label = source.includes('@') ? 'Email' : source return ( - + - {item.source} + {label} ) } From e7907a194e95ece20d40a2a5cd41946ca6e4573e Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Mon, 31 Aug 2026 15:22:18 +0500 Subject: [PATCH 3/4] Inbox: responsive fixes for the split layout - List column yields (34%, floor 280px) instead of holding 420px, so the detail pane stays readable in the 980-1200px band. - Detail pane is a size container: its two-column info grid collapses on the pane own width, and long names/emails wrap instead of clipping. - Tab strip wraps on narrow screens instead of running off-canvas. Co-Authored-By: Claude Fable 5 --- frontend/src/screens/Inbox.jsx | 1 + frontend/src/styles/styles.css | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 4d14d64..e15ce4e 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -998,6 +998,7 @@ export default function Inbox() {
{ setTab(t) diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 3328e89..f91ebf1 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -979,7 +979,15 @@ canvas { width: 100%; max-width: 100%; display: block; } /* Split inbox layout */ .split { display: grid; grid-template-columns: 380px 1fr; gap: 0; min-height: 560px; } .split-list { border-right: 1px solid var(--border); overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); } -.split-detail { overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); } +.split-detail { overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); container-type: inline-size; } +/* The detail pane can be narrow while the viewport is wide (split layout), + so viewport media queries cannot see it: the pane is a size container and + its two-column field grid collapses on the pane's own width. */ +@container (max-width: 560px) { + .split-detail .info-grid { grid-template-columns: 1fr; } + .split-detail .profile-hero { flex-wrap: wrap; } +} +.split-detail .ph-name { overflow-wrap: anywhere; } .inbox-item { display: flex; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--border); cursor: pointer; transition: .12s; position: relative; } .inbox-item:hover { background: var(--bg-sunken); } .inbox-item.active { background: var(--primary-soft); } @@ -994,8 +1002,11 @@ canvas { width: 100%; max-width: 100%; display: block; } .ii-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; } .ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; } /* Inbox sidebar only: fit the list instead of scrolling sideways. - Username (.ii-name) and subject (.ii-pos) are left alone. */ -.inbox-split { grid-template-columns: minmax(0, 420px) 1fr; } + Username (.ii-name) and subject (.ii-pos) are left alone. + The list column yields (34%, floor 280px) instead of holding a hard 420px, + so the detail pane keeps a readable width in the 980-1200px band where the + split has not collapsed to one column yet. */ +.inbox-split { grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); } .inbox-queue { overflow-x: hidden; min-width: 0; } .inbox-queue .inbox-item { min-width: 0; } /* Name + time on row 1, subject on row 2, chips span the full width under From 8380be0d8a83499591ce60758b751b4ec222a183 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 31 Aug 2026 15:51:52 +0500 Subject: [PATCH 4/4] recieved time implemented --- .dockerignore | 1 + .gitignore | 3 + backend/credentials/.gitkeep | 0 .../application_default_credentials.json | 9 ++- backend/g_sheet/plugins.py | 80 ++++++++++++++++++- backend/g_sheet/store_session.py | 78 ++++++++++++++++++ backend/g_sheet/views.py | 4 +- backend/inbox/models.py | 2 +- backend/job/candidate/models.py | 5 +- backend/job/interviews/serializers.py | 2 + backend/requirements.txt | 1 + docker-compose.yml | 5 ++ frontend/src/api/interviews.js | 11 ++- frontend/src/screens/Calendar.jsx | 7 +- frontend/src/screens/Interviews.jsx | 2 +- 15 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 backend/credentials/.gitkeep create mode 100644 backend/g_sheet/store_session.py diff --git a/.dockerignore b/.dockerignore index 2ba274f..45b7c83 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,6 +13,7 @@ **/.env **/.env.* !**/.env.example +backend/credentials/*.json **/__pycache__/ **/*.py[cod] diff --git a/.gitignore b/.gitignore index 115e33b..5f319a3 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ frontend/dist/ # Uploaded content — user data, never in git backend/uploads/ +# Google OAuth ADC / Desktop client secrets — never commit +backend/credentials/*.json + **.pdf # Per-machine alembic autogen revisions only — the old bare `**_**_**.py` # also swallowed any module with two underscores (e.g. test_talent_plugins.py). diff --git a/backend/credentials/.gitkeep b/backend/credentials/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/credentials/application_default_credentials.json b/backend/credentials/application_default_credentials.json index 776c982..dfb1d51 100644 --- a/backend/credentials/application_default_credentials.json +++ b/backend/credentials/application_default_credentials.json @@ -1,8 +1,11 @@ { - "account": "", + "type": "authorized_user", "client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com", "client_secret": "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", "refresh_token": "1//03C8LMkk-9mSyCgYIARAAGAMSNwF-L9IrAcPhShsp3cDeprSlqI-P6lArpmbyzu-PcKiLfQ5gC3H_MEk930IaKfewy3cxP3T0Oo8", - "type": "authorized_user", - "universe_domain": "googleapis.com" + "universe_domain": "googleapis.com", + "account": "ahmed.mujtaba@utopiabrands.com", + "token": "ya29.a0AdMD6Eg_6meQs84gTmiyhzZp7C-JlZeJU6-ECm6twwAcMqvfvRvyvs5LQGbAhHarHzZF-jiU-sicebJmxXIN4l6hNDXoaHcrojuhq--hj2oSBWojiEKaGIgLKPM8frdspz_wVANrwkFwpIKhN3RpWID9mJCt7N6IFaNrZtgStakdF0sVCKKVttE7qWK0vIvJT3HZHpVbaCgYKAX8SARASFQHGX2MiQvJqWStYQDgYFK4E6GJ9Zw0207", + "expiry": "2026-08-31T09:52:09Z", + "quota_project_id": "hrms-ats-portal" } diff --git a/backend/g_sheet/plugins.py b/backend/g_sheet/plugins.py index 04fb1c2..d0cee32 100644 --- a/backend/g_sheet/plugins.py +++ b/backend/g_sheet/plugins.py @@ -5,10 +5,13 @@ family and lets g_sheet/views.py translate that into HTTPException. Auth reuses the credentials already on disk (authorized_user ADC + a valid refresh token). Nothing here launches a browser, runs InstalledAppFlow, or reads stdin. +After a successful refresh, store_authorized_session writes the ADC JSON back so +the session can be copied to Linux prod. Re-auth lives in g_sheet/store_session.py. """ from __future__ import annotations +import json import logging import os import random @@ -31,12 +34,11 @@ from g_sheet.enums import ( MonthNormalisation, ) -load_dotenv() - logger=logging.getLogger("g_sheet.plugins") # backend/ — GOOGLE_APPLICATION_CREDENTIALS is stored relative to it ("credentials/..."). ROOT=Path(__file__).resolve().parent.parent +load_dotenv(ROOT/".env") SCOPES=[ "https://www.googleapis.com/auth/spreadsheets", @@ -47,6 +49,9 @@ SPREADSHEET_ID=os.getenv("SPREADSHEET_ID") SPREADSHEET_NAME=os.getenv("SPREADSHEET_NAME") SPREADSHEET_URL=os.getenv("SPREADSHEET_URL") GOOGLE_APPLICATION_CREDENTIALS=os.getenv("GOOGLE_APPLICATION_CREDENTIALS") +GOOGLE_OAUTH_CLIENT_ID_FILE=os.getenv("GOOGLE_OAUTH_CLIENT_ID_FILE") +GOOGLE_CLOUD_PROJECT=os.getenv("GOOGLE_CLOUD_PROJECT") +GOOGLE_ACCOUNT=os.getenv("GOOGLE_ACCOUNT") # 429 and 5xx are transient; every other 4xx is a bad request that a retry repeats. RETRY_ATTEMPTS=3 @@ -94,6 +99,73 @@ def resolve_credentials_path(credentials_path=None): return path +def resolve_client_secret_path(client_secret_path=None): + """Absolute path to the Desktop OAuth client json (credentials/client_secret.json).""" + raw=client_secret_path or GOOGLE_OAUTH_CLIENT_ID_FILE + if not raw: + return None + path=Path(raw) + if not path.is_absolute(): + path=ROOT/path + return path + + +def _expiry_iso(expiry): + if expiry is None: + return None + if expiry.tzinfo is None: + return expiry.strftime("%Y-%m-%dT%H:%M:%SZ") + return expiry.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _authorized_user_adc(credentials): + """gcloud-compatible authorized_user payload. google.auth.default() requires type.""" + payload={ + "type":"authorized_user", + "client_id":credentials.client_id, + "client_secret":credentials.client_secret, + "refresh_token":credentials.refresh_token, + "universe_domain":getattr(credentials,"universe_domain",None) or "googleapis.com", + "account":getattr(credentials,"account",None) or GOOGLE_ACCOUNT or "", + } + token=getattr(credentials,"token",None) + if token: + payload["token"]=token + expiry=_expiry_iso(getattr(credentials,"expiry",None)) + if expiry: + payload["expiry"]=expiry + if GOOGLE_CLOUD_PROJECT: + payload["quota_project_id"]=GOOGLE_CLOUD_PROJECT + return payload + + +def store_authorized_session(credentials,credentials_path=None): + """Persist an authorized_user session to GOOGLE_APPLICATION_CREDENTIALS. + + Service-account key files are left untouched (no refresh_token to rotate). + A persist failure is logged, never raised — the in-memory token still works. + """ + path=resolve_credentials_path(credentials_path) + if path is None: + logger.warning("GOOGLE_APPLICATION_CREDENTIALS is not configured; session not stored") + return None + if not getattr(credentials,"refresh_token",None) or not getattr(credentials,"client_id",None): + return None + try: + path.parent.mkdir(parents=True,exist_ok=True) + tmp=path.with_name(path.name+".tmp") + tmp.write_text(json.dumps(_authorized_user_adc(credentials),indent=2)+"\n",encoding="utf-8") + tmp.replace(path) + try: + os.chmod(path,0o600) + except OSError: + pass + except OSError as e: + logger.warning("could not persist Google authorized session: %s",e) + return None + return path + + def load_credentials(credentials_path=None,scopes=None): """Build scoped ADC credentials and refresh them once. Never prompts.""" path=resolve_credentials_path(credentials_path) @@ -108,10 +180,11 @@ def load_credentials(credentials_path=None,scopes=None): raise except Exception as e: raise SheetsAuthError(f"Google credential refresh failed: {e}") + store_authorized_session(credentials,credentials_path) return credentials -def ensure_fresh(credentials): +def ensure_fresh(credentials,credentials_path=None): """Refresh only when the token has actually gone stale — not on every call.""" if credentials is None: raise SheetsAuthError("Google credentials are not initialised") @@ -121,6 +194,7 @@ def ensure_fresh(credentials): credentials.refresh(Request()) except Exception as e: raise SheetsAuthError(f"Google credential refresh failed: {e}") + store_authorized_session(credentials,credentials_path) return credentials diff --git a/backend/g_sheet/store_session.py b/backend/g_sheet/store_session.py new file mode 100644 index 0000000..67672b5 --- /dev/null +++ b/backend/g_sheet/store_session.py @@ -0,0 +1,78 @@ +"""Capture a Google authorized_user session into credentials/. + +Run on a machine with a browser (Windows/macOS). Copy the resulting JSON to +Linux prod — the API never opens a browser. + + cd backend + python g_sheet/store_session.py + python g_sheet/store_session.py --force # re-consent, mint a new refresh token +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# `python g_sheet/store_session.py` puts this file's dir on sys.path, not backend/. +_BACKEND=Path(__file__).resolve().parent.parent +if str(_BACKEND) not in sys.path: + sys.path.insert(0,str(_BACKEND)) + +from g_sheet.plugins import ( + SCOPES, + SheetsAuthError, + load_credentials, + resolve_client_secret_path, + resolve_credentials_path, + store_authorized_session, +) + + +def _authorize_browser(client_secret_path): + try: + from google_auth_oauthlib.flow import InstalledAppFlow + except ImportError as e: + raise SystemExit( + "google-auth-oauthlib is required for browser login. " + "pip install google-auth-oauthlib==1.4.0" + ) from e + if client_secret_path is None or not client_secret_path.exists(): + raise SystemExit( + "OAuth client file not found. Set GOOGLE_OAUTH_CLIENT_ID_FILE " + "(credentials/client_secret.json)." + ) + flow=InstalledAppFlow.from_client_secrets_file(str(client_secret_path),SCOPES) + return flow.run_local_server(port=0,prompt="consent",access_type="offline") + + +def main(argv=None): + parser=argparse.ArgumentParser(description="Store a Google authorized_user session on disk.") + parser.add_argument( + "--force", + action="store_true", + help="Ignore the existing ADC file and open a browser consent screen.", + ) + args=parser.parse_args(argv) + path=resolve_credentials_path() + if path is None: + raise SystemExit("GOOGLE_APPLICATION_CREDENTIALS is not set.") + credentials=None + if not args.force: + try: + credentials=load_credentials() + except SheetsAuthError as e: + print(f"existing session unusable ({e}); opening browser…",file=sys.stderr) + if credentials is None: + credentials=_authorize_browser(resolve_client_secret_path()) + stored=store_authorized_session(credentials) + else: + stored=path + if stored is None: + raise SystemExit("failed to write the authorized session file") + print(f"stored authorized session: {stored}") + return 0 + + +if __name__=="__main__": + raise SystemExit(main()) diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index 80e1fe7..88df88e 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -85,13 +85,13 @@ class SheetClient(Sheet): their own client. """ if self.client is not None: - return ensure_fresh(self.credentials) and self.client + return ensure_fresh(self.credentials,self.credentials_path) and self.client with self._lock: if self.client is None: self.credentials=load_credentials(self.credentials_path,self.scopes) self.client=build_sheets_client(self.credentials) else: - ensure_fresh(self.credentials) + ensure_fresh(self.credentials,self.credentials_path) return self.client async def _values(self): diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 15ab605..5fec8ce 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -820,7 +820,7 @@ class Inbox_Messages(SQLModel, table=True): ): statement = cls._apply_filters( - select(cls).order_by(cls.created_at.desc(),cls.id.desc()), + select(cls).order_by(cls.message_received_time.desc(),cls.id.desc()), search, isread, application_status, assigned, is_duplicate, no_suggestions, processing_state, ) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 62ac1c6..4f03884 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -864,7 +864,10 @@ class Interviews(SQLModel, table=True): def _with_inbox_message(cls): from inbox.models import Inbox - return selectinload(cls.inbox).selectinload(Inbox.messages) + return selectinload(cls.inbox).options( + selectinload(Inbox.messages), + selectinload(Inbox.user), + ) @classmethod async def get_interview_by_id(cls, session: AsyncSession, record_id): diff --git a/backend/job/interviews/serializers.py b/backend/job/interviews/serializers.py index 7a2bebd..afb4d0d 100644 --- a/backend/job/interviews/serializers.py +++ b/backend/job/interviews/serializers.py @@ -1,6 +1,7 @@ def serialize_interview(row, *, job_title=None) -> dict: inbox=getattr(row,"inbox",None) user=getattr(inbox,"user",None) if inbox else None + uid=getattr(user,"id",None) or getattr(row,"user_id",None) return { "id": str(row.id), "inbox_id": row.inbox_id, @@ -9,6 +10,7 @@ def serialize_interview(row, *, job_title=None) -> dict: "interview_type": row.interview_type, "interview_status": row.interview_status, "candidate_name": user.name if user else None, + "user_id": str(uid) if uid else None, "job_title": job_title or None, "graph_event_id": row.graph_event_id or None, "web_link": row.web_link or None, diff --git a/backend/requirements.txt b/backend/requirements.txt index 39032c9..44436be 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -50,6 +50,7 @@ openpyxl==3.1.5 google-api-python-client==2.198.0 # Sheets v4 client in g_sheet/plugins.py google-auth==2.56.3 # ADC + refresh in g_sheet/plugins.py google-auth-httplib2==0.4.1 # transport used by googleapiclient +google-auth-oauthlib==1.4.0 # InstalledAppFlow in g_sheet/store_session.py only # --- AWS S3 (s3/) ---------------------------------------------------------- boto3==1.40.49 # S3 PutObject / DeleteObject in s3/plugins.py diff --git a/docker-compose.yml b/docker-compose.yml index d1d34fc..bd40d62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,13 +51,18 @@ x-backend-env: &backend-env # Shared CV storage. Named volume so API + workers see the same files. # Optional docker-compose.dev.yml remounts ./backend/inbox/decoded_attachments. +# Google authorized_user ADC is bind-mounted so a refresh-token rotation on +# disk survives image rebuilds. Copy the JSON onto the host; do not bake it. x-attachments: &attachments - attachments-data:/app/inbox/decoded_attachments + - ./backend/credentials:/app/credentials x-backend-service: &backend-service build: *backend-build image: hrms-backend:local working_dir: /app + volumes: + - ./backend/credentials:/app/credentials env_file: # backend/.env is the source of truth (plain DB_* + PROD_ENV). - ./backend/.env diff --git a/frontend/src/api/interviews.js b/frontend/src/api/interviews.js index 119a76d..cea80ee 100644 --- a/frontend/src/api/interviews.js +++ b/frontend/src/api/interviews.js @@ -95,10 +95,12 @@ export function update(interviewId, { instant, type, status } = {}) { * render. * * serialize_interview returns the interview columns plus optional calendar sync - * fields (`graph_event_id`, `web_link`) and `job_title`. Meeting mode, duration, - * interviewer list and feedback verdict still have no source — they stay absent - * rather than defaulted. Screens that already hydrate `jobTitle` from the - * application row keep doing so as a fallback. + * fields (`graph_event_id`, `web_link`), `job_title`, and `user_id` (inbox.user + * id, falling back to the denorm column). Meeting mode, duration, interviewer + * list and feedback verdict still have no source — they stay absent rather than + * defaulted. Screens that already hydrate `jobTitle` from the application row + * keep doing so as a fallback; `userId` prefers the interview row so unassigned + * applications still open a profile. */ export function toInterviewView(row) { const whenRaw = row.interview_date || row.interview_time @@ -111,6 +113,7 @@ export function toInterviewView(row) { status: row.interview_status || 'Scheduled', when: when && !Number.isNaN(when.getTime()) ? when : null, jobTitle: row.job_title || null, + userId: row.user_id ?? null, graphEventId: row.graph_event_id || null, webLink: row.web_link || null, } diff --git a/frontend/src/screens/Calendar.jsx b/frontend/src/screens/Calendar.jsx index 0f0c1d8..51286f4 100644 --- a/frontend/src/screens/Calendar.jsx +++ b/frontend/src/screens/Calendar.jsx @@ -71,8 +71,9 @@ export default function Calendar() { }, }) - /* Job title and the candidate's user id are not on the interview row; the - application supplies both. One extra request for the whole screen. */ + /* Job title is hydrated from the pipeline board; user id prefers the + interview row so unassigned applications (absent from the board) still + open a profile. One extra request for the whole screen. */ const appsQuery = useApplications() const appByInbox = useMemo(() => byInboxId(appsQuery.data), [appsQuery.data]) @@ -84,7 +85,7 @@ export default function Calendar() { return { ...iv, jobTitle: iv.jobTitle || app?.jobTitle || null, - userId: app?.userId ?? null, + userId: iv.userId ?? app?.userId ?? null, } }), [monthQuery.data, appByInbox], diff --git a/frontend/src/screens/Interviews.jsx b/frontend/src/screens/Interviews.jsx index eb86c3c..056978a 100644 --- a/frontend/src/screens/Interviews.jsx +++ b/frontend/src/screens/Interviews.jsx @@ -103,7 +103,7 @@ export default function Interviews() { return { ...iv, jobTitle: iv.jobTitle || app?.jobTitle || null, - userId: app?.userId ?? null, + userId: iv.userId ?? app?.userId ?? null, } }, [appByInbox],