Merge pull request 'Correct_Recieved_time' (#39) from Correct_Recieved_time into main
Deploy to S3 / deploy (push) Successful in 38s Details

Reviewed-on: #39
pull/40/head^2
ahmed.mujtaba 2026-08-31 10:52:53 +00:00
commit 615d127775
25 changed files with 607 additions and 51 deletions

View File

@ -13,6 +13,7 @@
**/.env
**/.env.*
!**/.env.example
backend/credentials/*.json
**/__pycache__/
**/*.py[cod]

3
.gitignore vendored
View File

@ -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).

View File

View File

@ -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"
}

View File

@ -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

View File

@ -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())

View File

@ -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):

View File

@ -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,
)

View File

@ -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):

View File

@ -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,

View File

@ -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);

View File

@ -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

View File

@ -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(...),

58
backend/talent/enums.py Normal file
View File

@ -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},
}

View File

@ -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)

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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,
}

View File

@ -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/<slug> link:
// { source, status, job_post_id, candidate, applied_at, same_job, applications }

View File

@ -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],

View File

@ -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 (
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }} title={item.source}>
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }} title={source}>
<span className="source-dot" />
<span className="source-chip-label">{item.source}</span>
<span className="source-chip-label">{label}</span>
</span>
)
}
@ -993,6 +998,7 @@ export default function Inbox() {
<div className="card">
<div style={{ margin: '0 16px', paddingTop: 8 }}>
<Tabs
className="tabs tabs-wrap"
value={tab}
onChange={(t) => {
setTab(t)

View File

@ -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],

View File

@ -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 }) {
>
<Icon name="eye" />
</button>
{canEdit && (() => {
const o = outreachProps(p)
return (
<>
<button
className="act-btn"
data-tip={o.star.tip}
aria-label={o.star.tip}
disabled={outreachBusy || o.star.disabled}
style={o.star.active ? { color: 'var(--warning)' } : undefined}
onClick={(e) => { e.stopPropagation(); onOutreach(p, o.star.next) }}
>
<Icon name="star" />
</button>
{o.check.shown && showContacted && (
<button
className="act-btn"
data-tip={o.check.tip}
aria-label={o.check.tip}
disabled={outreachBusy}
style={o.check.active ? { color: 'var(--success)' } : undefined}
onClick={(e) => { e.stopPropagation(); onOutreach(p, o.check.next) }}
>
<Icon name="check-circle" />
</button>
)}
</>
)
})()}
<button
className="act-btn"
data-tip="Dismiss"
data-tip="Dismiss (removes from all tabs)"
aria-label="Dismiss profile"
disabled={dismissing}
onClick={(e) => { e.stopPropagation(); onDismiss(p) }}
@ -219,12 +285,13 @@ function ProfileCard({ p, onView, onDismiss, dismissing }) {
}
/** Full LinkedIn profile: hero + about + skills + employment/education history. */
function TalentProfileDetail({ profileId, onClose }) {
function TalentProfileDetail({ profileId, onClose, canEdit, onOutreach, outreachBusy }) {
const detailQuery = useQuery({
queryKey: qk.talent.profile(profileId),
queryFn: () => talentApi.getProfile(profileId),
})
const p = detailQuery.data?.data ? talentApi.toProfileDetailView(detailQuery.data.data) : null
const o = p ? outreachProps(p) : null
return (
<Modal
@ -234,6 +301,30 @@ function TalentProfileDetail({ profileId, onClose }) {
onClose={onClose}
footer={
<>
{p && canEdit && (
<>
<button
className="btn"
data-tip={o.star.tip}
disabled={outreachBusy || o.star.disabled}
onClick={() => onOutreach(p, o.star.next)}
>
<Icon name="star" />
{p.outreachStatus === 'sourced' ? 'Shortlist' : 'Un-shortlist'}
</button>
{o.check.shown && (
<button
className="btn"
data-tip={o.check.tip}
disabled={outreachBusy}
onClick={() => onOutreach(p, o.check.next)}
>
<Icon name="check-circle" />
{p.outreachStatus === 'contacted' ? 'Undo contacted' : 'Mark contacted'}
</button>
)}
</>
)}
{p && (
<a className="btn btn-primary" href={p.linkedinUrl} target="_blank" rel="noreferrer">
<Icon name="linkedin" /> Open LinkedIn
@ -262,6 +353,18 @@ function TalentProfileDetail({ profileId, onClose }) {
{p.location && <Badge className="b-plain b-indigo badge-plain">{p.location}</Badge>}
<Badge className="b-gray">LinkedIn</Badge>
<AppliedBadge applied={p.alreadyApplied} />
{p.shortlistedAt && (
<Badge className="b-amber">
<Icon name="star" /> Shortlisted {fmtDate(p.shortlistedAt)}
{p.shortlistedByName ? ` by ${p.shortlistedByName}` : ''}
</Badge>
)}
{p.contactedAt && (
<Badge className="b-green">
<Icon name="check-circle" /> Contacted {fmtDate(p.contactedAt)}
{p.contactedByName ? ` by ${p.contactedByName}` : ''}
</Badge>
)}
{p.lastSeenAt && (
<Badge className="b-plain b-indigo badge-plain">Found {fmtDate(p.lastSeenAt)}</Badge>
)}
@ -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() {
)
) : (
<>
<div className="flex items-center gap-8 mb-18">
<Tabs
className="tabs tabs-wrap"
value={tab}
onChange={(t) => { setTab(t); setVisibleCount(10) }}
tabs={tabs}
/>
<div className="flex items-center gap-8 mb-18" style={{ marginTop: 12 }}>
<div className="toolbar-search">
<Icon name="search" />
<input
@ -555,24 +690,41 @@ export default function Talent() {
{visible.length} of {profiles.length} profile{profiles.length === 1 ? '' : 's'}
</span>
</div>
<div className="grid g-3">
{visible.slice(0, visibleCount).map((p) => (
<ProfileCard
key={p.id}
p={p}
onView={(profile) => setViewProfileId(profile.id)}
onDismiss={(profile) => dismissing.mutate(profile)}
dismissing={dismissing.isPending}
/>
))}
</div>
{visible.length === 0 && tab !== 'all' && !search.trim() ? (
tab === 'shortlisted' ? (
<EmptyState icon="star" title="No shortlisted profiles yet">
Star a profile in the All tab to build your outreach list.
</EmptyState>
) : (
<EmptyState icon="check-circle" title="No one marked contacted yet">
After messaging a shortlisted person on LinkedIn, mark them contacted
so the team knows they have been reached.
</EmptyState>
)
) : (
<div className="grid g-3">
{visible.slice(0, visibleCount).map((p) => (
<ProfileCard
key={p.id}
p={p}
onView={(profile) => setViewProfileId(profile.id)}
onDismiss={(profile) => dismissing.mutate(profile)}
dismissing={dismissing.isPending}
canEdit={canEdit}
onOutreach={handleOutreach}
outreachBusy={outreach.isPending}
showContacted={tab !== 'all'}
/>
))}
</div>
)}
<div className="flex items-center gap-8" style={{ justifyContent: 'center', marginTop: 18 }}>
{visible.length > visibleCount ? (
<button className="btn" onClick={() => setVisibleCount((n) => n + 10)}>
<Icon name="chevron-down" />
Show more ({visible.length - visibleCount} remaining)
</button>
) : (
) : tab === 'all' ? (
<button
className="btn"
disabled={runInFlight || starting.isPending}
@ -581,13 +733,19 @@ export default function Talent() {
<Icon name={runInFlight ? 'clock' : 'search'} />
{runInFlight ? 'Sourcing…' : 'Search LinkedIn for more'}
</button>
)}
) : null}
</div>
</>
)}
{viewProfileId && (
<TalentProfileDetail profileId={viewProfileId} onClose={() => setViewProfileId(null)} />
<TalentProfileDetail
profileId={viewProfileId}
onClose={() => setViewProfileId(null)}
canEdit={canEdit}
onOutreach={handleOutreach}
outreachBusy={outreach.isPending}
/>
)}
{confirmOpen && (

View File

@ -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