Merge remote-tracking branch 'origin/main' into Talha

# Conflicts:
#	backend/inbox/views.py
#	backend/job/candidate/serializers.py
#	backend/job/candidate/views.py
#	frontend/src/api/candidates.js
#	frontend/src/screens/Candidates.jsx
#	frontend/src/screens/Inbox.jsx
pull/73/head^2
Talha Ahmed 2026-09-07 13:19:39 +05:00
commit 05ca7bce8f
79 changed files with 3787 additions and 552 deletions

View File

@ -928,7 +928,7 @@ own keys with `os.getenv` from the same file.
| `APIFY_MAX_COST_USD` | `1.0` | Sent as `maxTotalChargeUsd`; Apify's minimum is $0.10 |
| `APIFY_TIMEOUT` | `30` | Per-request httpx timeout, seconds |
| `APIFY_EXCLUDE_COMPANIES` | `Utopia Brands,Utopia Deals` | Own companies: current employees are filtered out server-side before profiles are stored (case-insensitive substring on current company, headline fallback) |
| `APIFY_EXCLUDE_COMPANY_URLS` | the Utopia Deals / Utopia Brands USA / Utopia Brands Pakistan pages | Full LinkedIn company URLs for the actor's `excludeCurrentCompanies` filter — stops those profiles being scraped (and billed) at all |
| `APIFY_exclude_company_URLS` | the Utopia Deals / Utopia Brands USA / Utopia Brands Pakistan pages | Full LinkedIn company URLs for the actor's `excludeCurrentCompanies` filter — stops those profiles being scraped (and billed) at all |
### OpenAI

View File

@ -5,7 +5,7 @@
"refresh_token": "1//03C8LMkk-9mSyCgYIARAAGAMSNwF-L9IrAcPhShsp3cDeprSlqI-P6lArpmbyzu-PcKiLfQ5gC3H_MEk930IaKfewy3cxP3T0Oo8",
"universe_domain": "googleapis.com",
"account": "ahmed.mujtaba@utopiabrands.com",
"token": "ya29.a0AdMD6EgILeNb9UszC7bQJbAcqX709J5ky3eM8MEuQayGwhDStmfnR5t7o192x-FPdt53Q29rYL69zqYrgofUqpwxoI_sPjBsb0wrLqYDo6zwJMTx4P5svM4jZJd9nrXzUEyp3uI81e9DQ3z6lIDKtm6aUTPWQ3fm33i2hxJM7i-svhi3OwjnLtpOUHDyw--v8rQLPHdfaCgYKATkSARASFQHGX2MiXDnutGLllH9DnNCBUKWi4Q0207",
"expiry": "2026-09-02T08:29:45Z",
"token": "ya29.a0AdMD6EgKB22VSy--W0qCtRkMOYECCDhvL4c14xNUSvizbooOBC-ctQeyWR_XybUqa6PZvQ0csVqrIDR6e_uQazKuTAxKPLgpgbTmJ96-sHWbj_981xNrcWe6JsxIpQuGLX9GiKSGa8y5t50ZgWbDy0ECUoOQDvzlUq-hgNdLve1ECxDG4twpL3-2ZpGgskHlhuRL4-PQaCgYKARISARASFQHGX2MiOWOjgYvNnX5ZWhBoYqO9Cg0207",
"expiry": "2026-09-02T16:27:05Z",
"quota_project_id": "hrms-ats-portal"
}

View File

@ -0,0 +1,334 @@
"""Drive CV extract wrapper for sheet ingest.
Hang `@extract_drive_cvs` on `SheetImport.import_sheet` only (the worker).
HTTP enqueue routes must not run this FormData rows do not exist yet.
Worker job pattern (same as a Taskiq message): create a temp dir for the run,
stream each Drive CV to a file, extract, write extracted_data, delete that file.
A finally block removes the job dir so a successful run leaves no CVs on disk.
One row at a time a plain sequential loop, no extra locks.
"""
from __future__ import annotations
import asyncio
import logging
import os
import shutil
import uuid
from datetime import datetime,timezone
from functools import wraps
from inspect import signature
from pathlib import Path
from dotenv import load_dotenv
from g_sheet.models import FormData
from g_sheet.plugins import (
SheetsApiError,
drive_file_id,
download_drive_file,
ensure_fresh,
load_credentials,
)
load_dotenv(Path(__file__).resolve().parent.parent/".env")
logger=logging.getLogger("g_sheet.decorators")
_MAX_RESUME_CHARS=int(os.getenv("MAX_RESUME_CHARS","60000"))
_MAX_PDF_SIZE_MB=int(os.getenv("MAX_PDF_SIZE_MB","10"))
_TEMP_ROOT=Path(__file__).resolve().parent/"tmp"/"cv_extract"
def build_extracted_data(
*,
status,
resume_link,
file_id=None,
filename=None,
mime_type=None,
text=None,
page_count=None,
truncated=None,
error_code=None,
error_message=None,
):
"""Stable JSON blob stored on form_data.extracted_data."""
return {
"status":status,
"resume_link":resume_link or "",
"file_id":file_id,
"filename":filename,
"mime_type":mime_type,
"text":text,
"page_count":page_count,
"truncated":truncated,
"char_count":len(text) if isinstance(text,str) else None,
"error_code":error_code,
"error_message":error_message,
"extracted_at":datetime.now(timezone.utc).isoformat(),
}
def _drive_error_code(status_code):
if status_code in (401,403):
return "DRIVE_FORBIDDEN"
if status_code==404:
return "DRIVE_FILE_NOT_FOUND"
if status_code==413:
return "PAYLOAD_TOO_LARGE"
if status_code in (400,415):
return "UNSUPPORTED_FILE_TYPE"
return "DRIVE_DOWNLOAD_FAILED"
def _prepare_drive_credentials(service):
"""Load/refresh the Google session. Never raises — None means skip extract."""
try:
if service is None:
return load_credentials()
creds=getattr(service,"credentials",None)
path=getattr(service,"credentials_path",None)
scopes=getattr(service,"scopes",None)
if creds is not None:
return ensure_fresh(creds,path)
return load_credentials(path,scopes)
except Exception:
logger.warning("Google Drive session unavailable; skipping CV extract")
return None
def _is_sheet_service(obj):
return obj is not None and hasattr(obj,"session") and hasattr(obj,"spreadsheet_id")
def _tab_from(result,args,kwargs):
if isinstance(result,dict) and result.get("tab"):
return result.get("tab")
if kwargs.get("tab"):
return kwargs.get("tab")
if args:
return args[0]
return None
def _should_ingest(result):
if not isinstance(result,dict):
return False
if result.get("error"):
return False
if result.get("status") in ("queued","running","failed"):
return False
if result.get("rows_read",1)==0 and result.get("inserted",1)==0:
return False
return True
def make_job_temp_dir(root=None):
"""Temp dir for one extract job. Caller must remove_job_temp_dir in finally."""
base=Path(root) if root else _TEMP_ROOT
base.mkdir(parents=True,exist_ok=True)
job_dir=base/uuid.uuid4().hex
job_dir.mkdir()
return job_dir
def remove_job_temp_dir(job_dir):
"""Delete leftover CVs and the job dir. No-op if missing."""
if not job_dir:
return
path=Path(job_dir)
if not path.exists():
return
shutil.rmtree(path,ignore_errors=True)
def _unlink(path):
if path is None:
return
try:
Path(path).unlink(missing_ok=True)
except OSError as e:
logger.warning("could not delete temp CV %s: %s",path,e)
def _extract_pdf(data,filename,max_chars):
from app.services.pdf import extract_resume,sanitize_filename
from job.candidate.plugins import normalize_spaced_text
resume=extract_resume(data,sanitize_filename(filename),max_chars)
return {
"text":normalize_spaced_text(resume.text),
"page_count":resume.page_count,
"truncated":resume.truncated,
}
def _download_and_extract(credentials,link,max_chars,max_bytes,dest_dir):
"""Stream one Drive file into dest_dir, extract, then delete that file."""
file_id=drive_file_id(link)
if not file_id:
return build_extracted_data(
status="skipped",
resume_link=link,
error_code="NOT_DRIVE_URL",
error_message="Resume link is not a Google Drive file URL",
)
dest=None
try:
downloaded=download_drive_file(
credentials,link,max_bytes=max_bytes,dest_dir=dest_dir,
)
dest=downloaded.get("path")
if dest is None:
return build_extracted_data(
status="failed",
resume_link=link,
file_id=downloaded.get("file_id") or file_id,
error_code="DRIVE_DOWNLOAD_FAILED",
error_message="Drive download did not write a file",
)
data=Path(dest).read_bytes()
try:
parsed=_extract_pdf(data,downloaded.get("filename") or "resume.pdf",max_chars)
finally:
data=b""
return build_extracted_data(
status="completed",
resume_link=link,
file_id=downloaded.get("file_id") or file_id,
filename=downloaded.get("filename"),
mime_type=downloaded.get("mime_type"),
text=parsed["text"],
page_count=parsed["page_count"],
truncated=parsed["truncated"],
)
except SheetsApiError as e:
return build_extracted_data(
status="failed",
resume_link=link,
file_id=file_id,
error_code=_drive_error_code(e.status_code),
error_message=(e.message or "")[:300],
)
except Exception as e:
from app.core.errors import ATSError
if isinstance(e,ATSError):
return build_extracted_data(
status="failed",
resume_link=link,
file_id=file_id,
error_code=e.error_code,
error_message=e.public_message,
)
logger.exception("drive download/extract failed for file_id=%s",file_id)
return build_extracted_data(
status="failed",
resume_link=link,
file_id=file_id,
error_code="DRIVE_DOWNLOAD_FAILED",
error_message="Drive download failed",
)
finally:
_unlink(dest)
async def extract_one_resume(credentials,resume_link,dest_dir,max_chars=None,max_bytes=None):
"""Download one Drive URL into dest_dir and return extracted_data JSON."""
if max_chars is None:
max_chars=_MAX_RESUME_CHARS
if max_bytes is None:
max_bytes=_MAX_PDF_SIZE_MB*1024*1024
link=(resume_link or "").strip()
return await asyncio.to_thread(
_download_and_extract,credentials,link,max_chars,max_bytes,dest_dir,
)
async def ingest_form_resume_links(session,sheet,credentials,temp_root=None):
"""One Drive file per resume_link: download → extract → DB → delete file.
The job temp dir is created at start and removed in finally so a finished
run leaves no CVs on disk (Taskiq worker cleanup).
"""
rows=await FormData.fetch_resume_links(session,sheet)
completed=0
failed=0
max_chars=_MAX_RESUME_CHARS
max_bytes=_MAX_PDF_SIZE_MB*1024*1024
job_dir=make_job_temp_dir(temp_root)
logger.info(
"drive CV extract starting tab=%s resumes=%s temp=%s",
sheet,len(rows),job_dir,
)
try:
for record_id,resume_link in rows:
payload=await extract_one_resume(
credentials,resume_link,job_dir,max_chars,max_bytes,
)
try:
saved=await FormData.set_extracted_data(session,record_id,payload)
except Exception:
logger.exception("could not persist extracted_data for %s",record_id)
failed+=1
try:
await session.rollback()
except Exception:
logger.exception("rollback after extracted_data persist failed")
continue
status=payload.get("status")
if status=="completed":
completed+=1
if saved is not None:
from g_sheet.scoring import enqueue_form_row_scores
await enqueue_form_row_scores(saved)
elif status=="failed":
failed+=1
logger.info(
"drive CV extract finished tab=%s extracted=%s failed=%s",
sheet,completed,failed,
)
return {"extracted":completed,"extract_failed":failed}
finally:
remove_job_temp_dir(job_dir)
def extract_drive_cvs(func):
"""Hang on SheetImport.import_sheet (worker ingest), not on HTTP enqueue.
After rows are inserted: one Drive download + extract per resume_link,
written to form_data.extracted_data at the end of each row.
Credentials load only if ingest will actually run.
"""
@wraps(func)
async def wrapper(*args,**kwargs):
result=await func(*args,**kwargs)
service=args[0] if args and _is_sheet_service(args[0]) else None
session=getattr(service,"session",None) if service is not None else None
rest=args[1:] if service is not None else args
tab=_tab_from(result,rest,kwargs)
if session is None or not tab or not _should_ingest(result):
logger.info(
"drive CV extract skipped tab=%s session=%s ingest=%s",
tab,session is not None,
_should_ingest(result) if isinstance(result,dict) else False,
)
return result
credentials=await asyncio.to_thread(_prepare_drive_credentials,service)
if credentials is None:
logger.warning("Google Drive session unavailable; skipping CV extract")
return result
try:
stats=await ingest_form_resume_links(session,tab,credentials)
except Exception:
logger.exception("drive CV extract after import of %s failed",tab)
stats={"extracted":0,"extract_failed":0}
if isinstance(result,dict):
result["extracted"]=stats.get("extracted",0)
result["extract_failed"]=stats.get("extract_failed",0)
return result
wrapper.__signature__=signature(func)
return wrapper

View File

@ -171,6 +171,8 @@ class FormDataColumn(str, Enum):
ID = "id"
SHEET = "sheet"
JOB_POST_ID = "job_post_id"
ASSIGNED_JOB_POST_ID = "assigned_job_post_id"
SUGGESTED_JOB_POST_IDS = "suggested_job_post_ids"
MANUAL_UPLOAD_CANDIDATE_ID = "manual_upload_candidate_id"
ROW_NUMBER = "row_number"
SERIAL_NO = "serial_no"
@ -189,6 +191,7 @@ class FormDataColumn(str, Enum):
CANDIDATE_EMAIL = "candidate_email"
PROFILE_LINK = "profile_link"
RESUME_LINK = "resume_link"
EXTRACTED_DATA = "extracted_data"
AREA_OF_EXPERTISE = "area_of_expertise"
REQUISITION_NUMBER = "requisition_number"
POSITION_APPLIED_FOR = "position_applied_for"
@ -233,9 +236,10 @@ FORM_DATA_FIELDS: tuple[str, ...] = tuple(member.value for member in FormDataCol
# -- Date parsing ------------------------------------------------------------
class DateFormat(str, Enum):
"""strptime patterns tried in definition order.
"""strptime patterns tried in definition order after numeric slash dates.
DD/MM before MM/DD: 14/10/20 is ambiguous and DD/MM is the local convention.
Numeric D/M vs M/D is resolved in parse_date (8/28 Aug 28, 28/8 28 Aug,
8/12 follows prefer_mdy). These patterns cover named months and ISO.
"""
D_MON_Y_DASH = "%d-%b-%Y"

View File

@ -28,9 +28,15 @@ class FormData(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
sheet: str = Field(nullable=False, index=True)
# Optional link to a job post. DB FK only — no ORM Relationship (avoids
# Recruiter-assigned job. DB FK only — no ORM Relationship (avoids
# pulling job_posts into the sheet worker metadata graph).
job_post_id: uuid.UUID | None = Field(default=None, index=True)
assigned_job_post_id: uuid.UUID | None = Field(default=None, index=True)
# ILIKE title matches from Position Applied For. One form row → many jobs.
# Suggested, not assigned. ATS scores each id separately.
suggested_job_post_ids: list[str] | None = Field(
default=None, sa_column=Column(JSONB),
)
# Set when this form row is promoted into the hiring pipeline (Users +
# manual_upload_candidate). Idempotency key for assign / shortlist.
manual_upload_candidate_id: uuid.UUID | None = Field(default=None, index=True)
@ -51,6 +57,8 @@ class FormData(SQLModel, table=True):
candidate_email: str | None = Field(default=None, index=True)
profile_link: str | None = Field(default=None)
resume_link: str | None = Field(default=None)
# Drive CV extract JSON written by @extract_drive_cvs after sheet ingest.
extracted_data: dict | None = Field(default=None, sa_column=Column(JSONB))
area_of_expertise: str | None = Field(default=None)
requisition_number: str | None = Field(default=None, index=True)
position_applied_for: str | None = Field(default=None)
@ -131,19 +139,65 @@ class FormData(SQLModel, table=True):
result = await session.execute(select(cls).where(cls.id == rid))
return result.scalars().first()
@classmethod
async def get_with_job(cls, session: AsyncSession, record_id, job_post_id):
"""Form row + one job it may be scored against (suggested or assigned)."""
from job.job_post.models import JobPosts
form = await cls.get_form_data_by_id(session, record_id)
if form is None:
return None, None
try:
jid = uuid.UUID(str(job_post_id))
except (TypeError, ValueError):
return None, None
if jid not in set(cls.score_job_ids(form)):
return None, None
job = await JobPosts.get_job_post_by_id(session, jid)
if job is None or job.is_deleted:
return None, None
return form, job
@staticmethod
def score_job_ids(row) -> list[uuid.UUID]:
"""Jobs ATS may score: assigned only, else every suggested id."""
if row is None:
return []
getter = row.get if isinstance(row, dict) else lambda key, default=None: getattr(row, key, default)
assigned = getter("assigned_job_post_id") or getter("job_post_id")
if assigned not in (None, ""):
try:
return [uuid.UUID(str(assigned))]
except (TypeError, ValueError):
return []
out: list[uuid.UUID] = []
seen: set[uuid.UUID] = set()
for raw in getter("suggested_job_post_ids") or []:
try:
uid = uuid.UUID(str(raw))
except (TypeError, ValueError):
continue
if uid not in seen:
seen.add(uid)
out.append(uid)
return out
@classmethod
async def set_job_post(cls, session: AsyncSession, record_id, job_post_id):
"""Set or clear job_post_id; returns the row or None if missing."""
"""Set or clear the recruiter assignment; returns the row or None if missing."""
row = await cls.get_form_data_by_id(session, record_id)
if not row:
return None
if job_post_id is None:
row.job_post_id = None
row.assigned_job_post_id = None
else:
try:
row.job_post_id = uuid.UUID(str(job_post_id))
uid = uuid.UUID(str(job_post_id))
except (TypeError, ValueError):
return None
row.job_post_id = uid
row.assigned_job_post_id = uid
row.updated_at = _now()
session.add(row)
await session.commit()
@ -190,6 +244,38 @@ class FormData(SQLModel, table=True):
await session.refresh(row)
return row
@classmethod
async def set_extracted_data(
cls, session: AsyncSession, record_id, extracted_data, *, commit: bool = True,
):
row = await cls.get_form_data_by_id(session, record_id)
if not row:
return None
row.extracted_data = extracted_data
row.updated_at = _now()
session.add(row)
if commit:
await session.commit()
await session.refresh(row)
return row
@classmethod
async def fetch_resume_links(cls, session: AsyncSession, sheet: str):
"""(id, resume_link) for one tab. Blank links are dropped."""
statement = (
select(cls.id, cls.resume_link)
.where(cls.sheet == sheet)
.where(cls.resume_link.is_not(None))
.order_by(cls.row_number)
)
result = await session.execute(statement)
rows = []
for record_id, link in result.all():
text = (link or "").strip()
if text:
rows.append((record_id, text))
return rows
@classmethod
async def fetch_form_data(
cls, session: AsyncSession, *, sheet=None, search=None,
@ -209,6 +295,44 @@ class FormData(SQLModel, table=True):
result = await session.execute(statement)
return result.scalars().all()
@classmethod
async def list_by_emails(cls, session: AsyncSession, emails):
"""Sheet applicants for these addresses. Promoted rows are omitted —
those already live on manual_upload_candidate."""
from job.job_post.models import JobPosts
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
if not lowers:
return []
assigned = func.coalesce(cls.assigned_job_post_id, cls.job_post_id)
result = await session.execute(
select(cls, JobPosts.title)
.outerjoin(JobPosts, assigned == JobPosts.id)
.where(func.lower(cls.candidate_email).in_(lowers))
.where(cls.manual_upload_candidate_id.is_(None))
.order_by(cls.created_at.desc())
)
rows = []
for rec, title in result.all():
job_id = rec.assigned_job_post_id or rec.job_post_id
rows.append({
"source": "form",
"email": (rec.candidate_email or "").strip().lower() or None,
"inbox_id": None,
"message_id": None,
"manual_upload_candidate_id": None,
"form_data_id": str(rec.id),
"candidate_id": None,
"job_post_id": str(job_id) if job_id else None,
"job_title": title or rec.position_applied_for or None,
"status": rec.processing_state or None,
"applied_at": (
rec.entry_date.isoformat() if rec.entry_date
else (rec.created_at.isoformat() if rec.created_at else None)
),
})
return rows
@classmethod
async def count_form_data(
cls, session: AsyncSession, *, sheet=None, search=None,
@ -296,6 +420,29 @@ class FormData(SQLModel, table=True):
await session.commit()
return {"deleted": deleted, "inserted": inserted}
@classmethod
async def stamp_suggested_job_posts(
cls, session: AsyncSession, records: list[dict],
) -> list[dict]:
"""Set suggested_job_post_ids from ILIKE title match on position_applied_for.
One applied-for title can match many job_posts. Blank or no match [].
Recruiter assignment (job_post_id / assigned_job_post_id) stays unset.
"""
from job.job_post.models import JobPosts
found = await JobPosts.ids_for_titles_ilike(
session,
[r.get("position_applied_for") for r in records],
)
for record in records:
applied = (record.get("position_applied_for") or "").strip()
hits = found.get(applied) or [] if applied else []
record["suggested_job_post_ids"] = [str(uid) for uid in hits]
record["job_post_id"] = None
record["assigned_job_post_id"] = None
return records
@staticmethod
def _cell(data: dict, key: str):
"""Sheet cell → stripped str, or None if missing/blank."""
@ -423,6 +570,14 @@ class SheetImportRun(SQLModel, table=True):
)
return result.scalars().first()
@classmethod
async def delete_failed(cls, session: AsyncSession, *, commit: bool = True):
"""Drop failed import rows so a new job is not blocked by them."""
result = await session.execute(delete(cls).where(cls.status == "failed"))
if commit:
await session.commit()
return result.rowcount
@classmethod
async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
row = cls(**fields)

View File

@ -11,6 +11,7 @@ the session can be copied to Linux prod. Re-auth lives in g_sheet/store_session.
from __future__ import annotations
import io
import json
import logging
import os
@ -25,6 +26,7 @@ from google.auth import default as google_auth_default
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from googleapiclient.http import MediaIoBaseDownload
from g_sheet.enums import (
ALIAS_TO_FIELD,
@ -206,6 +208,130 @@ def build_sheets_client(credentials):
raise SheetsApiError(f"Could not build the Sheets client: {e}")
def build_drive_client(credentials):
"""Drive v3 client. Same ADC session as Sheets; cache_discovery=False under threads."""
try:
return build("drive","v3",credentials=credentials,cache_discovery=False)
except Exception as e:
raise SheetsApiError(f"Could not build the Drive client: {e}")
_DRIVE_FILE_ID_PATTERNS=(
re.compile(r"/file/d/([a-zA-Z0-9_-]+)"),
re.compile(r"/document/d/([a-zA-Z0-9_-]+)"),
re.compile(r"[?&]id=([a-zA-Z0-9_-]+)"),
re.compile(r"/d/([a-zA-Z0-9_-]+)"),
)
_GOOGLE_APPS_SHORTCUT="application/vnd.google-apps.shortcut"
_GOOGLE_APPS_DOCUMENT="application/vnd.google-apps.document"
_GOOGLE_APPS_PREFIX="application/vnd.google-apps."
def drive_file_id(url):
"""Extract a Drive/Docs file id from a Google URL, or None if it is not one."""
raw=(url or "").strip()
if not raw:
return None
lowered=raw.lower()
if "drive.google.com" not in lowered and "docs.google.com" not in lowered:
return None
if "/folders/" in lowered:
return None
for pattern in _DRIVE_FILE_ID_PATTERNS:
match=pattern.search(raw)
if match:
return match.group(1)
return None
def _drive_file_meta(drive,file_id):
request=drive.files().get(
fileId=file_id,
fields="id,name,mimeType,size,shortcutDetails",
supportsAllDrives=True,
)
return execute(request,"drive file metadata")
def _download_media(request,dest_path=None):
"""Stream a Drive media request. dest_path set → write that file; else return bytes."""
try:
if dest_path is None:
buf=io.BytesIO()
downloader=MediaIoBaseDownload(buf,request)
done=False
while not done:
_,done=downloader.next_chunk()
return buf.getvalue()
path=Path(dest_path)
path.parent.mkdir(parents=True,exist_ok=True)
with path.open("wb") as fh:
downloader=MediaIoBaseDownload(fh,request)
done=False
while not done:
_,done=downloader.next_chunk()
return path
except HttpError as e:
status=_status_of(e)
raise SheetsApiError(f"drive download failed: {_reason_of(e)}",status or 502)
def _cv_dest_path(dest_dir,file_id,filename):
dest_dir=Path(dest_dir).resolve()
suffix=Path(filename or "resume.pdf").suffix.lower() or ".pdf"
if suffix not in (".pdf",".doc",".docx"):
suffix=".pdf"
safe_id=re.sub(r"[^a-zA-Z0-9_-]","",file_id or "") or "file"
dest=(dest_dir/f"{safe_id}{suffix}").resolve()
if dest.parent!=dest_dir:
raise SheetsApiError("invalid download path",400)
return dest
def download_drive_file(credentials,url,*,max_bytes=None,dest_dir=None):
"""Download one Drive file via the existing Google session.
When dest_dir is set the file is streamed to disk and `path` is returned
(`data` is None). Otherwise `data` holds the bytes (tests / callers without a
work dir).
"""
file_id=drive_file_id(url)
if not file_id:
raise SheetsApiError("not a Google Drive file URL",400)
ensure_fresh(credentials)
drive=build_drive_client(credentials)
meta=_drive_file_meta(drive,file_id)
if (meta.get("mimeType") or "")==_GOOGLE_APPS_SHORTCUT:
target=(meta.get("shortcutDetails") or {}).get("targetId")
if not target:
raise SheetsApiError("Drive shortcut has no target",400)
file_id=target
meta=_drive_file_meta(drive,file_id)
mime=meta.get("mimeType") or ""
name=meta.get("name") or "resume.pdf"
size=meta.get("size")
if max_bytes is not None and size is not None:
try:
if int(size)>max_bytes:
raise SheetsApiError("The file exceeds the size limit.",413)
except (TypeError,ValueError):
pass
if mime==_GOOGLE_APPS_DOCUMENT:
request=drive.files().export_media(fileId=file_id,mimeType="application/pdf")
if not name.lower().endswith(".pdf"):
name=f"{name}.pdf"
elif mime.startswith(_GOOGLE_APPS_PREFIX):
raise SheetsApiError("unsupported Google file type",415)
else:
request=drive.files().get_media(fileId=file_id,supportsAllDrives=True)
if dest_dir is None:
data=_download_media(request)
return {"file_id":file_id,"filename":name,"mime_type":mime,"data":data,"path":None}
dest=_cv_dest_path(dest_dir,file_id,name)
_download_media(request,dest)
return {"file_id":file_id,"filename":name,"mime_type":mime,"data":None,"path":dest}
def _status_of(error):
status=getattr(getattr(error,"resp",None),"status",None)
if status is None:
@ -320,6 +446,7 @@ def stringify_rows(rows):
_TIME_RE=re.compile(r"(\d{1,2}:\d{2}\s*(?:[AaPp][Mm])?)")
_DAY_ORDINAL_RE=re.compile(r"\b(\d+)(st|nd|rd|th)\b",re.I)
_DIGIT_RE=re.compile(r"\d")
_NUMERIC_DATE_RE=re.compile(r"^(\d{1,2})([/\-.])(\d{1,2})\2(\d{2,4})$")
_AGE_RE=re.compile(r"\d+")
_SCORE_RE=re.compile(r"\d+")
_SALARY_UNIT_RE=re.compile(
@ -331,6 +458,7 @@ _CURRENCY_STRIP_RE=re.compile(r"(?:rs\.?|pkr|inr|usd|\$|€|£)",re.I)
# Every typed column key the mapper must emit (uniform dicts for bulk insert).
_FORM_DATA_COLUMN_KEYS=tuple(field.value for field in FormDataField)+(
"age_raw","current_salary_value","expected_salary_value","job_post_id",
"assigned_job_post_id","suggested_job_post_ids",
)
@ -383,8 +511,31 @@ def _normalise_month_spellings(text):
return text
def parse_date(value):
"""Tolerant date parse → aware UTC datetime, or None. Never raises."""
def _from_numeric_date(first,second,year,prefer_mdy):
"""Slash/dash/dot numeric dates. 8/28 is MDY; 28/8 is DMY; 8/12 is ambiguous."""
if year<100:
year+=2000
if first>12 and 1<=second<=12:
day,month=first,second
elif second>12 and 1<=first<=12:
month,day=first,second
elif prefer_mdy:
month,day=first,second
else:
day,month=first,second
try:
return datetime(year,month,day,tzinfo=timezone.utc)
except ValueError:
return None
def parse_date(value,prefer_mdy=False):
"""Tolerant date parse → aware UTC datetime, or None. Never raises.
prefer_mdy=True for Google Form Timestamp (US M/D/YYYY). Leave False for
local DD/MM fields like date of birth. Unambiguous values (8/28, 28/8)
are resolved from the numbers, not the flag.
"""
if value is None:
return None
text=str(value).strip()
@ -405,6 +556,17 @@ def parse_date(value):
date_part=_normalise_month_spellings(date_part)
date_part=re.sub(r"\s+"," ",date_part).strip(" ,;")
numeric=_NUMERIC_DATE_RE.match(date_part)
if numeric:
parsed=_from_numeric_date(
int(numeric.group(1)),
int(numeric.group(3)),
int(numeric.group(4)),
prefer_mdy,
)
if parsed is not None:
return parsed
for fmt in DateFormat:
try:
return datetime.strptime(date_part,fmt.value).replace(tzinfo=timezone.utc)
@ -414,8 +576,11 @@ def parse_date(value):
def parse_date_time(value):
"""(datetime|None, time_string|None) — fills entry_time when the cell carries one."""
parsed=parse_date(value)
"""(datetime|None, time_string|None) — fills entry_time when the cell carries one.
Google Form Timestamp is M/D/YYYY, so 8/12/2026 is 12 Aug, not 8 Dec.
"""
parsed=parse_date(value,prefer_mdy=True)
if value is None:
return parsed,None
text=str(value).strip()

169
backend/g_sheet/scoring.py Normal file
View File

@ -0,0 +1,169 @@
"""ATS-score a form_data CV against every linked job post.
A form row can match many jobs (suggested_job_post_ids). Recruiter assignment
is assigned_job_post_id. If assigned is set, ATS scores only that job; else
every suggested id. Resume text comes from extracted_data. Each
(form_data_id, job_post_id) pair lands as its own ats_results row.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from app.models.scoring import CompletedCandidate
from app.services.pdf import ExtractedResume
from app.services.scoring import score_batch
from db_setup import session_scope
from g_sheet.models import FormData
from inbox.models import AtsResults
from job.candidate.plugins import build_job_description, get_scorer, get_scoring_settings
from job.job_post.models import JobPosts
logger = logging.getLogger("g_sheet.scoring")
def resume_text_from_extracted(payload) -> str | None:
"""Usable CV text from form_data.extracted_data, or None."""
if not isinstance(payload, dict):
return None
if payload.get("status") != "completed":
return None
text = (payload.get("text") or "").strip()
return text or None
def _band(score) -> str:
if score is None:
return ""
return "Strong Match" if score >= 82 else "Potential Match" if score >= 65 else "Weak Match"
def serialize_form_ats(row) -> dict:
"""One current ats_results row for a Sheet Forms applicant."""
return {
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"overall_score": row.overall_score,
"band": row.band or None,
"computed_at": row.computed_at.isoformat() if row.computed_at else None,
}
async def enqueue_form_score(form_data_id, job_post_id) -> None:
"""Queue ATS for one form row against one job. Broker-down only logs."""
if not form_data_id or not job_post_id:
return
await enqueue_form_scores(form_data_id, [job_post_id])
async def enqueue_form_scores(form_data_id, job_post_ids) -> None:
"""Queue ATS for one form row against each job. Broker-down only logs."""
if not form_data_id:
return
from inbox.tasks import score_form_data
seen: set[str] = set()
for raw in job_post_ids or []:
job_id = str(raw or "").strip()
if not job_id or job_id in seen:
continue
seen.add(job_id)
try:
await score_form_data.kicker().with_labels(
created_at=datetime.now(timezone.utc).isoformat(),
correlation_id=str(form_data_id),
queue="inbox",
).kiq(str(form_data_id), job_id)
except Exception as exc:
logger.warning(
"could not queue form ats score for %s vs %s: %s",
form_data_id, job_id, exc,
)
async def enqueue_form_row_scores(form_row) -> None:
"""Queue ATS: assigned job only, else every suggested job."""
if form_row is None:
return
assigned=getattr(form_row,"assigned_job_post_id",None) or getattr(form_row,"job_post_id",None)
if assigned:
await enqueue_form_score(form_row.id,assigned)
return
job_ids=FormData.score_job_ids(form_row)
if not job_ids:
return
await enqueue_form_scores(form_row.id,job_ids)
async def score_form_against_job(form_data_id: str, job_id: str) -> dict:
"""Score one Sheet Forms CV against one job. Idempotent per (form, job)."""
try:
uuid.UUID(str(form_data_id))
uuid.UUID(str(job_id))
except (TypeError, ValueError):
return {"status": "skipped", "reason": "invalid_ids"}
async with session_scope() as session:
existing = await AtsResults.get_for_form_job(session, form_data_id, job_id)
if existing is not None:
return {"status": "already_scored"}
form_row, job = await FormData.get_with_job(session, form_data_id, job_id)
if form_row is None or job is None:
return {"status": "skipped", "reason": "no_join"}
text = resume_text_from_extracted(form_row.extracted_data)
if not text:
return {"status": "skipped", "reason": "no_extract"}
filename = (form_row.extracted_data or {}).get("filename") or "resume.pdf"
page_count = int((form_row.extracted_data or {}).get("page_count") or 1)
truncated = bool((form_row.extracted_data or {}).get("truncated"))
settings = get_scoring_settings()
jd = build_job_description(job)
if len(jd) > settings.max_jd_chars:
return {"status": "skipped", "reason": "jd_too_large"}
form_pk = form_row.id
job_pk = job.id
resume = ExtractedResume(
filename=str(filename),
candidate_id=str(form_pk),
text=text,
page_count=page_count,
truncated=truncated,
)
scored = await score_batch(
[resume],
job_description=jd,
scorer=get_scorer(),
concurrency=1,
)
result = scored[0] if scored else None
if not isinstance(result, CompletedCandidate):
error = getattr(result, "error_code", None) if result is not None else "MODEL_UNAVAILABLE"
logger.warning("form ats failed form_data=%s job=%s code=%s", form_data_id, job_id, error)
return {"status": "failed", "error_code": error}
async with session_scope() as session:
existing = await AtsResults.get_for_form_job(session, form_pk, job_pk)
if existing is not None:
return {"status": "already_scored"}
job = await JobPosts.get_job_post_by_id(session, job_pk)
if job is None or job.is_deleted:
return {"status": "skipped", "reason": "job_gone"}
await AtsResults.insert_result(session, {
"inbox_id": None,
"user_id": None,
"candidate_id": None,
"form_data_id": form_pk,
"job_post_id": job_pk,
"overall_score": float(result.match_score),
"band": _band(result.match_score),
"model_name": settings.openai_model,
"is_current": True,
})
return {
"status": "scored",
"overall_score": result.match_score,
"band": _band(result.match_score),
"job_post_id": str(job_pk),
}

View File

@ -111,6 +111,8 @@ def serialize_form_data(row) -> dict:
out[key] = _iso(value)
elif isinstance(value, uuid.UUID):
out[key] = str(value)
elif key == "suggested_job_post_ids":
out[key] = [str(v) for v in (value or []) if v not in (None, "")]
else:
out[key] = value
return out
@ -128,6 +130,8 @@ def serialize_import(report: dict) -> dict:
"ages_parsed": report.get("ages_parsed", 0),
"salaries_parsed": report.get("salaries_parsed", 0),
"unmapped_headers": report.get("unmapped_headers") or [],
"extracted": report.get("extracted", 0),
"extract_failed": report.get("extract_failed", 0),
"error": report.get("error"),
}
@ -142,6 +146,8 @@ def serialize_import_all(reports: list[dict]) -> dict:
"failed": len(failed),
"inserted": sum(r.get("inserted", 0) for r in ok),
"deleted": sum(r.get("deleted", 0) for r in ok),
"extracted": sum(r.get("extracted", 0) for r in ok),
"extract_failed": sum(r.get("extract_failed", 0) for r in reports),
"reports": [serialize_import(r) for r in reports],
}

View File

@ -49,13 +49,30 @@ async def import_sheets(run_id:str) -> dict:
try:
acquired=await client.set(_LOCK_KEY,run_id,nx=True,ex=_LOCK_TTL)
if not acquired:
return await _fail(run_id,"another sheet import is already running")
holder=await client.get(_LOCK_KEY)
# Crash/restart redelivers the same run_id while the TTL lock is
# still set. Failing that as "another import" strands the lock
# until expiry and every later click also bounces.
if holder==run_id:
await client.expire(_LOCK_KEY,_LOCK_TTL)
logger.warning("sheet import %s reclaimed its own stale lock",run_id)
else:
logger.warning(
"sheet import %s skipped: lock held by %s",run_id,holder,
)
return await _fail(run_id,"another sheet import is already running")
try:
async with session_scope() as session:
row=await SheetImportRun.get_by_id(session,run_id)
if not row:
raise PermanentTaskError(f"import run {run_id} not found")
if row.status=="failed":
await SheetImportRun.delete_failed(session)
return {"status":"failed","error":row.error}
if row.status=="completed":
return {"status":"completed","report":row.report}
await SheetImportRun.delete_failed(session)
await SheetImportRun.update_run(session,run_id,{
"status":"running",
"started_at":datetime.now(timezone.utc),

View File

@ -17,10 +17,12 @@ Hierarchy:
import asyncio
import logging
import threading
import uuid
from datetime import datetime,timezone
from fastapi import HTTPException
from g_sheet.decorators import extract_drive_cvs
from g_sheet.plugins import (
SCOPES,
SPREADSHEET_ID,
@ -232,6 +234,7 @@ class SheetHealth(SheetRead):
class SheetImport(SheetRead):
"""Google Sheet → FormData import + import-run tracking."""
@extract_drive_cvs
async def import_sheet(self,tab):
"""Read one tab from Google Sheets and replace its FormData rows."""
session=self._require_session()
@ -247,6 +250,7 @@ class SheetImport(SheetRead):
FormData.from_sheet_row(tab,row_number,record)
for row_number,record in indexed
]
mapped=await FormData.stamp_suggested_job_posts(session,mapped)
result=await FormData.replace_sheet(session,tab,mapped)
return serialize_import({
"tab":tab,
@ -279,15 +283,18 @@ class SheetImport(SheetRead):
return serialize_import_all(reports)
async def start_import(self,current_user=None,tab=None):
"""Enqueue a sheet import on the shared Taskiq worker; return the run row.
"""Enqueue a sheet import.
If a queued/running import already exists, return it instead of stacking another.
At the start of every new job: queued/running keep that job;
failed delete those rows and start this one; completed start this one.
"""
session=self._require_session()
active=await SheetImportRun.get_active(session)
if active:
return serialize_import_run(active)
await SheetImportRun.delete_failed(session)
created_by=None
if isinstance(current_user,dict) and current_user.get("id"):
created_by=SheetImportRun._as_uuid(current_user.get("id"))
@ -329,39 +336,97 @@ class SheetFormData(Sheet):
"""FormData DB mirror — query / delete only (no Google client)."""
async def _hydrate_job_posts(self,items):
"""Attach matching job_posts (title == position_applied_for) + assigned_job_post.
"""Attach suggested job_posts, assigned_job_post, and per-job ATS scores.
No AI suggestions form applicants already name the role. One query for
titles on the page, one for any assigned ids.
Preferred source is suggested_job_post_ids (ILIKE matches stored on
import). Legacy rows without that list still title-match. ATS is one
current score per (form, job).
"""
if not items:
return items
from g_sheet.scoring import serialize_form_ats
from inbox.models import AtsResults
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
session=self._require_session()
def _job_payload(post):
payload=serialize_job_post(post)
if post.is_deleted or not post.is_active:
payload={**payload,"unavailable":True}
return payload
suggested_ids=[]
for item in items:
for raw in item.get("suggested_job_post_ids") or []:
if raw:
suggested_ids.append(raw)
assigned_ids=[]
for item in items:
aid=item.get("assigned_job_post_id") or item.get("job_post_id")
if aid:
assigned_ids.append(aid)
wanted=list(dict.fromkeys([*suggested_ids,*assigned_ids]))
by_id={}
if wanted:
for post in await JobPosts.get_by_ids(session,wanted,active_only=False):
by_id[str(post.id)]=_job_payload(post)
titles=[(item.get("position_applied_for") or "").strip() for item in items]
titles=[t for t in titles if t]
by_title={}
if titles:
needs_title=any(not (item.get("suggested_job_post_ids") or []) for item in items)
if titles and needs_title:
for post in await JobPosts.get_by_titles(session,titles):
key=(post.title or "").strip().lower()
payload=serialize_job_post(post)
if post.is_deleted or not post.is_active:
payload={**payload,"unavailable":True}
by_title.setdefault(key,[]).append(payload)
by_title.setdefault(key,[]).append(_job_payload(post))
assigned_ids=[item.get("job_post_id") for item in items if item.get("job_post_id")]
assigned_map={}
if assigned_ids:
for post in await JobPosts.get_by_ids(session,assigned_ids,active_only=False):
assigned_map[str(post.id)]=serialize_job_post(post)
ats_by_form=await AtsResults.get_current_for_forms(
session,[item.get("id") for item in items],
)
for item in items:
key=(item.get("position_applied_for") or "").strip().lower()
item["job_posts"]=list(by_title.get(key) or [])
aid=item.get("job_post_id")
item["assigned_job_post"]=assigned_map.get(str(aid)) if aid else None
suggested=[str(raw) for raw in (item.get("suggested_job_post_ids") or []) if raw]
item["suggested_job_post_ids"]=suggested
if suggested:
posts=[]
for sid in suggested:
payload=by_id.get(sid)
if payload is None:
posts.append({"id":sid,"unavailable":True})
else:
posts.append(dict(payload))
item["job_posts"]=posts
else:
key=(item.get("position_applied_for") or "").strip().lower()
item["job_posts"]=[dict(p) for p in (by_title.get(key) or [])]
aid=item.get("assigned_job_post_id") or item.get("job_post_id")
item["assigned_job_post_id"]=str(aid) if aid else None
item["assigned_job_post"]=by_id.get(str(aid)) if aid else None
fid=item.get("id")
try:
form_uid=uuid.UUID(str(fid)) if fid else None
except (TypeError,ValueError):
form_uid=None
scores=[serialize_form_ats(row) for row in (ats_by_form.get(form_uid) or [])]
item["ats_results"]=scores
score_by_job={
str(s["job_post_id"]):s for s in scores if s.get("job_post_id")
}
for post in item["job_posts"]:
hit=score_by_job.get(str(post.get("id")))
if hit:
post["overall_score"]=hit.get("overall_score")
post["band"]=hit.get("band")
assigned_score=score_by_job.get(str(aid)) if aid else None
if assigned_score and assigned_score.get("overall_score") is not None:
item["ats_score"]=round(float(assigned_score.get("overall_score")))
else:
nums=[s.get("overall_score") for s in scores if s.get("overall_score") is not None]
item["ats_score"]=round(float(max(nums))) if nums else None
return items
async def get_form_data(
@ -378,6 +443,8 @@ class SheetFormData(Sheet):
processing_state=processing_state,is_duplicate=is_duplicate,
)
items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
from job.candidate.views import CandidateView
items=await CandidateView(session=session).attach_application_history(items)
return items,total
async def get_form_data_by_id(self,record_id):
@ -386,10 +453,11 @@ class SheetFormData(Sheet):
if not row:
raise HTTPException(status_code=404,detail="Form data not found")
items=await self._hydrate_job_posts([serialize_form_data(row)])
return items[0]
from job.candidate.views import CandidateView
return await CandidateView(session=session).attach_application_history(items[0])
async def assign_job_post(self,record_id,job_post_id):
"""Set or clear form_data.job_post_id (same contract as inbox assign).
"""Set or clear form_data.assigned_job_post_id (same contract as inbox assign).
Setting a job promotes the row into Users + manual_upload_candidate so
Candidates / Talent Pool / Pipeline can see it (platform tag: Form).
@ -405,6 +473,8 @@ class SheetFormData(Sheet):
raise HTTPException(status_code=404,detail="Form data not found")
if job_post_id is not None:
await self._promote_to_application(updated)
from g_sheet.scoring import enqueue_form_score
await enqueue_form_score(updated.id,job_post_id)
return await self.get_form_data_by_id(record_id)
async def set_processing_state(self,record_id,processing_state,current_user=None):

View File

@ -160,6 +160,56 @@ class Inbox(SQLModel, table=True):
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@classmethod
async def list_applications_by_emails(cls, session: AsyncSession, emails):
"""Inbox applications whose user or sender address is in `emails`."""
from job.job_post.models import JobPosts
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
if not lowers:
return []
result = await session.execute(
select(
cls.id.label("inbox_id"),
cls.user_id,
cls.message_id,
Users.email,
Inbox_Messages.message_from,
Inbox_Messages.assigned_job_post_id,
Inbox_Messages.application_status,
Inbox_Messages.message_received_time,
cls.created_at,
JobPosts.title,
)
.join(Users, cls.user_id == Users.id)
.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
.outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id)
.where(or_(
func.lower(Users.email).in_(lowers),
func.lower(Inbox_Messages.message_from).in_(lowers),
))
.order_by(cls.created_at.desc())
)
rows = []
for row in result.mappings().all():
email = (row["email"] or row["message_from"] or "").strip().lower() or None
status = row["application_status"]
applied = row["message_received_time"] or row["created_at"]
rows.append({
"source": "inbox",
"email": email,
"inbox_id": row["inbox_id"],
"message_id": str(row["message_id"]) if row["message_id"] else None,
"manual_upload_candidate_id": None,
"form_data_id": None,
"candidate_id": None,
"job_post_id": str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None,
"job_title": row["title"] or None,
"status": status.value if status else None,
"applied_at": applied.isoformat() if hasattr(applied, "isoformat") else (applied or None),
})
return rows
@classmethod
async def linkedin_urls_by_message_ids(cls, session: AsyncSession, message_ids) -> dict:
"""users.linkedin_url keyed by inbox_messages.id for one list page."""
@ -214,8 +264,10 @@ class Inbox(SQLModel, table=True):
return or_(Users.name.ilike(pattern), Users.email.ilike(pattern))
@classmethod
async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None):
async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None,job_post_ids=None):
try:
if job_post_ids is not None and not list(job_post_ids):
return []
options=[selectinload(cls.messages)]
if user_id:
options.extend([
@ -234,6 +286,11 @@ class Inbox(SQLModel, table=True):
qry = qry.where(cls.user_id == user_id)
if search:
qry = qry.where(cls._candidate_search_filter(search))
if job_post_ids is not None:
qry = (
qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
)
# Most-recent-first is the list contract; id breaks ties so a page
# boundary can't drop or repeat a row when created_at collides.
qry = qry.order_by(cls.created_at.desc(), cls.id.desc())
@ -247,9 +304,11 @@ class Inbox(SQLModel, table=True):
raise HTTPException(status_code=500,detail=str(e))
@classmethod
async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None):
async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None,job_post_ids=None):
"""Result-set size for the same predicate get_candidate_profile pages over."""
try:
if job_post_ids is not None and not list(job_post_ids):
return 0
qry = (
select(func.count())
.select_from(cls)
@ -261,11 +320,40 @@ class Inbox(SQLModel, table=True):
qry = qry.where(cls.user_id == user_id)
if search:
qry = qry.where(cls._candidate_search_filter(search))
if job_post_ids is not None:
qry = (
qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
)
result = await session.execute(qry)
return result.scalar_one()
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@classmethod
async def get_users_by_job_post_id(cls,session:AsyncSession,job_post_id):
"""Distinct users.id on inbox rows assigned to this job post.
Join is inbox inbox_messages.assigned_job_post_id only suggestions
are not a link. Invalid ids yield an empty set, not a 500.
"""
try:
try:
jid=uuid.UUID(str(job_post_id))
except (TypeError,ValueError,AttributeError):
return set()
qry=(
select(cls.user_id)
.join(Inbox_Messages,cls.message_id==Inbox_Messages.id)
.where(Inbox_Messages.assigned_job_post_id==jid)
.where(cls.user_id.is_not(None))
.distinct()
)
result=await session.execute(qry)
return {uid for uid in result.scalars().all() if uid is not None}
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@classmethod
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
"""Optional department / recruiter via inbox_messages → job_posts."""
@ -1461,6 +1549,8 @@ class AtsResults(SQLModel, table=True):
candidate_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="candidates.id")
user_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="users.id")
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
# Sheet Forms scores: no inbox/user. Identity is (form_data_id, job_post_id).
form_data_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="form_data.id")
overall_score: float = Field(default=0.0)
band: str = Field(default="")
is_current: bool = Field(default=True)
@ -1519,6 +1609,53 @@ class AtsResults(SQLModel, table=True):
)
return result.scalars().first()
@classmethod
async def get_for_form_job(cls, session: AsyncSession, form_data_id, job_post_id):
"""Any score for this form_data row against this job — current or superseded."""
fid = cls._as_uuid(form_data_id)
jid = cls._as_uuid(job_post_id)
if fid is None or jid is None:
return None
result = await session.execute(
select(cls)
.where(cls.form_data_id == fid, cls.job_post_id == jid)
.order_by(cls.computed_at.desc())
)
return result.scalars().first()
@classmethod
async def get_current_for_form_job(cls, session: AsyncSession, form_data_id, job_post_id=None):
"""Current Sheet Forms score, optionally pinned to one job post."""
fid = cls._as_uuid(form_data_id)
if fid is None:
return None
qry = select(cls).where(cls.form_data_id == fid, cls.is_current == True) # noqa: E712
jid = cls._as_uuid(job_post_id) if job_post_id is not None else None
if jid is not None:
qry = qry.where(cls.job_post_id == jid)
result = await session.execute(qry.order_by(cls.computed_at.desc()))
return result.scalars().first()
@classmethod
async def get_current_for_forms(cls, session: AsyncSession, form_data_ids) -> dict:
"""Current Sheet Forms scores grouped by form_data_id (newest first)."""
keys = []
for raw in form_data_ids or []:
uid = cls._as_uuid(raw)
if uid is not None:
keys.append(uid)
if not keys:
return {}
result = await session.execute(
select(cls)
.where(cls.form_data_id.in_(keys), cls.is_current == True) # noqa: E712
.order_by(cls.computed_at.desc())
)
grouped: dict = {}
for row in result.scalars():
grouped.setdefault(row.form_data_id, []).append(row)
return grouped
@classmethod
async def resolve_identity(cls, session: AsyncSession, email, candidate_id):
"""XOR identity for a score row from the scored candidate's email.
@ -1568,15 +1705,20 @@ class AtsResults(SQLModel, table=True):
Inbox scores chain on inbox_id and repoint inbox.ats_id (candidate_id
may be NULL). Upload scores chain on candidate_id, or on (user_id,
job_post_id) when identity resolved to a user. Flush the INSERT first:
with no relationship() edge the unit of work emits the UPDATEs first,
and the FKs reject a pointer to a row not yet inserted."""
job_post_id) when identity resolved to a user. Sheet Forms scores chain
on (form_data_id, job_post_id) with inbox_id and user_id left NULL.
Flush the INSERT first: with no relationship() edge the unit of work
emits the UPDATEs first, and the FKs reject a pointer to a row not yet
inserted."""
inbox_id = fields.get("inbox_id")
candidate_id = fields.get("candidate_id")
user_id = fields.get("user_id")
job_post_id = fields.get("job_post_id")
form_data_id = fields.get("form_data_id")
if inbox_id is not None:
prev = await cls.get_current_for_inbox(session, inbox_id)
elif form_data_id is not None:
prev = await cls.get_current_for_form_job(session, form_data_id, job_post_id)
elif candidate_id is not None:
prev = await cls.get_current_for_candidate(session, candidate_id)
elif user_id is not None:

View File

@ -62,6 +62,27 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict:
return {"status":"scored","results":len(results)}
@broker.task(
task_name="g_sheet.score_form",
retry_on_error=True,
max_retries=MAX_RETRIES,
delay=RETRY_DELAY,
)
async def score_form_data(form_data_id:str,job_id:str) -> dict:
"""ATS-score one Sheet Forms CV (extracted_data) against one job post."""
from g_sheet.scoring import score_form_against_job
try:
uuid.UUID(str(form_data_id))
uuid.UUID(str(job_id))
except (TypeError,ValueError):
raise PermanentTaskError("form_data_id and job_id must be uuids")
result=await score_form_against_job(form_data_id,job_id)
if result.get("status")=="failed":
raise RuntimeError(result.get("error_code") or "form ats failed")
return result
@broker.task(
task_name="inbox.score_message",
retry_on_error=True,

View File

@ -271,7 +271,7 @@ class Email:
item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id)
else:
item["assigned_job_post"]=None
return item
return await cv.attach_application_history(item)
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None):
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
@ -281,14 +281,18 @@ class Email:
else:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,light=True)
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages])
return [serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages]
items=[serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages]
from job.candidate.views import CandidateView
return await CandidateView(session=self.session).attach_application_history(items)
async def get_application_by_id(self,record_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message:
raise HTTPException(status_code=404,detail="Application not found")
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[message.id])
return serialize_application(message,linkedin_url=urls.get(message.id))
item=serialize_application(message,linkedin_url=urls.get(message.id))
from job.candidate.views import CandidateView
return await CandidateView(session=self.session).attach_application_history(item)
async def queue_rematch(self,record_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)

View File

@ -231,6 +231,7 @@ async def fetch_users(
role_id:Optional[int]=Query(None),
top:Optional[int]=Query(None),
skip:Optional[int]=Query(None),
assigned_job_post_id:Optional[str]=Query(None),
search:Optional[str]=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
@ -242,7 +243,7 @@ async def fetch_users(
detail="Hiring managers can only list candidates on their requisitions",
)
service=User(session=session)
data=await service.get_users(role_id=role_id,top=top,skip=skip)
data=await service.get_users(role_id=role_id,top=top,skip=skip,assigned_job_post_id=assigned_job_post_id)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
@ -254,6 +255,7 @@ async def fetch_users(
async def count_candidate_users(
role_id:Optional[int]=Query(None),
search:Optional[str]=Query(None),
assigned_job_post_id:Optional[str]=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
@ -265,7 +267,7 @@ async def count_candidate_users(
detail="Hiring managers can only list candidates on their requisitions",
)
service=User(session=session)
total=await service.count_users(search=search,role_id=role_id)
total=await service.count_users(search=search,role_id=role_id,assigned_job_post_id=assigned_job_post_id)
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
except HTTPException:
raise
@ -734,6 +736,7 @@ async def fetch_job_posts(
skip=skip,
ids=id_list,
active_only=active_only,
current_user=current_user,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@ -863,7 +866,7 @@ async def fetch_jobs(
data,total=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
top=top,skip=skip,active_only=active_only,
top=top,skip=skip,active_only=active_only,current_user=current_user,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@ -889,7 +892,7 @@ async def export_jobs(
data,_=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
top=None,skip=0,active_only=active_only,
top=None,skip=0,active_only=active_only,current_user=current_user,
)
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
return Response(
@ -942,19 +945,23 @@ async def fetch_manager_candidates(
async def fetch_candidate(
user_id:str=Query(None),
limit:int=Query(10,ge=1,le=100),
assigned_job_post_id:UUID=Query(None),
offset:int=Query(0,ge=0),
search:str=Query(None),
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.get_candidate(
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
)
total=await service.count_candidates(user_id=user_id,search=search) if isinstance(data,list) else 1
total=await service.count_candidates(
user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
) if isinstance(data,list) else 1
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
@ -962,16 +969,33 @@ async def fetch_candidate(
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/applications/fetch")
async def fetch_candidate_applications(
email:str=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.get_application_history(email)
return JSONResponse(content={"data":data,"total":len(data.get("applications") or []),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/candidate/update")
async def update_candidate(
user_id:str=Query(...),
payload:CandidateUpdate=...,
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True),current_user)
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
@ -982,7 +1006,7 @@ async def update_candidate(
@router.get("/candidate/history/fetch")
async def fetch_candidate_history(
user_id:str=Query(...),
limit:int=Query(200,ge=1,le=500),
limit:int=Query(10,ge=1,le=100),
offset:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
@ -1007,7 +1031,7 @@ async def fetch_interview(
recruiter_id:str=Query(None),
top:int=Query(None),
skip:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_VIEW,PermissionTag.CANDIDATES_VIEW,require_all=False)),
session: AsyncSession = Depends(get_session),
):
try:
@ -1035,7 +1059,7 @@ async def fetch_interview(
@router.post("/interview/create")
async def create_interview(
payload:InterviewCreate,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_CREATE,PermissionTag.CANDIDATES_CREATE,require_all=False)),
session: AsyncSession = Depends(get_session),
):
try:
@ -1052,7 +1076,7 @@ async def create_interview(
async def update_interview(
interview_id:str=Query(...),
payload:InterviewUpdate=...,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_EDIT,PermissionTag.CANDIDATES_EDIT,require_all=False)),
session: AsyncSession = Depends(get_session),
):
try:
@ -1069,12 +1093,13 @@ async def update_interview(
async def fetch_notes(
note_id:str=Query(None),
user_id:str=Query(None),
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user)
data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user,created_by=created_by)
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@ -1086,12 +1111,13 @@ async def fetch_notes(
@router.post("/notes/create")
async def create_note(
payload:NoteCreate,
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
data=await service.create_note(payload.model_dump(exclude_unset=True),current_user)
data=await service.create_note(payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
@ -1103,12 +1129,13 @@ async def create_note(
async def update_note(
note_id:str=Query(...),
payload:NoteUpdate=...,
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True),current_user)
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise

View File

@ -390,16 +390,52 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
return result.scalars().first()
@classmethod
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None):
async def list_by_emails(cls, session: AsyncSession, emails):
"""Every manual/form/cv-bank row for these addresses, newest first."""
from job.job_post.models import JobPosts
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
if not lowers:
return []
result = await session.execute(
select(cls, JobPosts.title)
.outerjoin(JobPosts, cls.job_post_id == JobPosts.id)
.where(func.lower(cls.candidate_email).in_(lowers))
.order_by(cls.created_at.desc())
)
rows = []
for rec, title in result.all():
status = (rec.status or "").strip() or None
rows.append({
"source": "manual",
"email": (rec.candidate_email or "").strip().lower() or None,
"inbox_id": None,
"message_id": None,
"manual_upload_candidate_id": str(rec.id),
"form_data_id": None,
"candidate_id": None,
"job_post_id": str(rec.job_post_id) if rec.job_post_id else None,
"job_title": title or None,
"status": status or "PENDING",
"applied_at": rec.created_at.isoformat() if rec.created_at else None,
})
return rows
@classmethod
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
"""Newest applications with a user + job for Talent Pool (manual / form)."""
from users.models import Users
if job_post_ids is not None and not list(job_post_ids):
return []
statement = (
select(cls)
.join(Users, cls.user_id == Users.id)
.where(cls.user_id.is_not(None), cls.job_post_id.is_not(None))
.order_by(cls.created_at.desc())
)
if job_post_ids is not None:
statement = statement.where(cls.job_post_id.in_(list(job_post_ids)))
if search:
like = f"%{search.strip()}%"
statement = statement.where(
@ -803,6 +839,38 @@ class Candidates(SQLModel, table=True):
)
return result.scalars().first()
@classmethod
async def list_by_emails(cls, session: AsyncSession, emails):
"""Scored `candidates` rows for these addresses (ATS, not pipeline stage)."""
from job.job_post.models import JobPosts
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
if not lowers:
return []
result = await session.execute(
select(cls, JobPosts.title)
.outerjoin(JobPosts, cls.job_id == JobPosts.id)
.where(func.lower(cls.candidate_email).in_(lowers))
.order_by(cls.created_at.desc())
)
rows = []
for rec, title in result.all():
email = (rec.candidate_email or "").strip().lower() or None
rows.append({
"source": "ats",
"email": email,
"inbox_id": None,
"message_id": None,
"manual_upload_candidate_id": None,
"form_data_id": None,
"candidate_id": str(rec.id),
"job_post_id": str(rec.job_id) if rec.job_id else None,
"job_title": title or rec.job_title or None,
"status": rec.status or None,
"applied_at": rec.created_at.isoformat() if rec.created_at else None,
})
return rows
@classmethod
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
existing = None

View File

@ -248,3 +248,35 @@ def serialize_manager_candidate(row, *, source) -> dict:
"ai_score": score,
"recommendation": band,
}
def serialize_application_history_item(row) -> dict:
"""One prior application / score / sheet row for a reapplicant lookup."""
return {
"source": row.get("source"),
"inbox_id": row.get("inbox_id"),
"message_id": row.get("message_id"),
"manual_upload_candidate_id": row.get("manual_upload_candidate_id"),
"form_data_id": row.get("form_data_id"),
"candidate_id": row.get("candidate_id"),
"job_post_id": row.get("job_post_id"),
"job_title": row.get("job_title"),
"status": row.get("status"),
"applied_at": row.get("applied_at"),
}
def serialize_application_history(email, *, user=None, present_in=None, applications=None) -> dict:
items = [serialize_application_history_item(row) for row in (applications or [])]
found = bool(user or present_in or items)
return {
"email": email,
"found": found,
"present_in": list(present_in or []),
"user": (
{"id": str(user.id), "name": user.name, "email": user.email}
if user is not None else None
),
"is_reapplicant": len(items) > 0,
"applications": items,
}

View File

@ -24,7 +24,8 @@ from job.candidate.plugins import (
get_scoring_settings,
normalize_spaced_text,
)
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
from g_sheet.models import FormData
from job.candidate.serializers import serialize_application_history,serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
@ -33,7 +34,7 @@ from job.history.views import HistoryRecorder
from job.notes.serializers import serialize_note
from job.candidate.plugins import extract_candidate_email
from users.models import Users
from users.permissions import is_hiring_manager
from users.permissions import is_hiring_manager,sees_all_candidates,scopes_to_own_requisitions
from employment_agent.plugins import parse_phone
load_dotenv()
@ -43,6 +44,63 @@ MANUAL_UPLOAD_TO_ADDRESS=os.getenv(
"MANUAL_UPLOAD_TO_ADDRESS","manual-cv-upload@hr-ats.local"
)
MANAGER_SCOPE_DETAIL="You can only access candidates allocated to jobs opened from your requisitions"
CREATOR_SCOPE_DETAIL="You can only access candidates allocated to jobs you created"
_HISTORY_TABLES=("users","candidates","manual_upload_candidate","form_data")
def _norm_email(value):
raw=(value or "").strip().lower()
if not raw:
return ""
if "<" in raw and ">" in raw:
inner=raw.rsplit("<",1)[-1]
raw=inner.split(">",1)[0].strip()
return raw
def _payload_email(payload):
if not isinstance(payload,dict):
return ""
return _norm_email(
payload.get("email")
or payload.get("candidate_email")
or payload.get("fromEmail")
)
def _is_current_application(item,payload):
"""True when `item` is the same row the list/detail payload is showing."""
if not isinstance(item,dict) or not isinstance(payload,dict):
return False
source=item.get("source")
if source=="inbox":
if payload.get("inbox_id") is not None and item.get("inbox_id") is not None:
try:
if int(payload["inbox_id"])==int(item["inbox_id"]):
return True
except (TypeError,ValueError):
pass
pid=payload.get("message_id")
if not pid and payload.get("inbox_id") is None and payload.get("sheet") is None:
pid=payload.get("id")
return bool(pid and item.get("message_id") and str(pid)==str(item["message_id"]))
if source=="manual":
pid=payload.get("manual_upload_candidate_id")
if not pid and payload.get("inbox_id") is None and payload.get("sheet") is None:
pid=payload.get("id")
return bool(pid and item.get("manual_upload_candidate_id") and str(pid)==str(item["manual_upload_candidate_id"]))
if source=="form":
if payload.get("sheet") is None:
return False
pid=payload.get("id")
return bool(pid and item.get("form_data_id") and str(pid)==str(item["form_data_id"]))
if source=="ats":
pid=payload.get("candidate_id") or payload.get("id")
return bool(
pid and item.get("candidate_id") and str(pid)==str(item["candidate_id"])
and (payload.get("match_score") is not None or payload.get("filename"))
)
return False
async def assigned_job_ids_for_user(session,user_id):
@ -78,15 +136,51 @@ async def job_id_for_application(session,inbox_id=None,manual_id=None):
return None,None
async def owned_job_ids_for_candidate_scope(session,current_user,created_by=False):
"""Job ids this user may see, or None when the list is unscoped.
requisitions.configure (or hiring-manager portal) jobs on their requisitions
/ assigned hiring_manager_id. Recruiter assignment on the job does not hide
those candidates. candidates.manage or admin None (all applications).
Otherwise current_recruiter_id when set, else created_by. Never role_id.
created_by=True skips current_recruiter_id and matches job_posts.created_by
to the session user (ignored when the user is requisition-scoped).
"""
if scopes_to_own_requisitions(current_user):
return await JobPosts.ids_for_manager(session,current_user.get("id"))
if sees_all_candidates(current_user):
return None
return await JobPosts.ids_for_creator(session,current_user.get("id"),created_by=created_by)
async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post_id=None,created_by=False):
"""None = unscoped list. [] = nothing visible. Else UUID list for the query."""
owned=await owned_job_ids_for_candidate_scope(session,current_user,created_by=created_by)
requested=JobPosts._as_uuid(assigned_job_post_id) if assigned_job_post_id is not None else None
if owned is None:
return [requested] if requested else None
if requested is not None:
return [requested] if requested in set(owned) else []
return list(owned)
def _scope_detail(current_user):
if scopes_to_own_requisitions(current_user):
return MANAGER_SCOPE_DETAIL
return CREATOR_SCOPE_DETAIL
async def assert_manager_candidate_access(
session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None,
session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None,created_by=False,
):
"""Hiring managers may only touch applications on jobs they own."""
if not is_hiring_manager(current_user):
"""Row access: requisition-owned jobs, unscoped (manage/admin), or jobs this user created."""
if sees_all_candidates(current_user) and not scopes_to_own_requisitions(current_user):
return
owned=set(await JobPosts.ids_for_manager(session,current_user.get("id")))
owned=await owned_job_ids_for_candidate_scope(session,current_user,created_by=created_by)
owned=set(owned or [])
detail=_scope_detail(current_user)
if not owned:
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
raise HTTPException(status_code=403,detail=detail)
job_id=JobPosts._as_uuid(job_post_id) if job_post_id is not None else None
uid=user_id
if job_id is None and (inbox_id is not None or manual_id is not None):
@ -95,9 +189,9 @@ async def assert_manager_candidate_access(
candidate_jobs=await assigned_job_ids_for_user(session,uid)
if candidate_jobs & owned:
return
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
raise HTTPException(status_code=403,detail=detail)
if job_id is None or job_id not in owned:
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
raise HTTPException(status_code=403,detail=detail)
async def parse_linkedin_url_from_cv(resume_text) -> str | None:
@ -439,13 +533,16 @@ class CandidateScoring:
rows,total=await Candidates.get_candidates_by_job(
self.session,job_id,limit=limit,offset=offset,
)
return [serialize_candidate(row) for row in rows],total
data=[serialize_candidate(row) for row in rows]
data=await CandidateView(session=self.session).attach_application_history(data)
return data,total
async def fetch_candidate_by_id(self,candidate_id):
row=await Candidates.get_candidate_by_id(self.session,candidate_id)
if row is None:
raise HTTPException(status_code=404,detail="Candidate not found")
return serialize_candidate(row)
data=serialize_candidate(row)
return await CandidateView(session=self.session).attach_application_history(data)
async def _score_and_persist(self,job_id,sources,source_kind,current_user):
job=await JobPosts.get_job_post_by_id(self.session,job_id)
@ -786,44 +883,54 @@ class CandidateView:
total=len(merged)
start=max(0,int(offset or 0))
cap=max(1,int(limit or 50))
return merged[start:start+cap],total
page=merged[start:start+cap]
return await self.attach_application_history(page),total
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None):
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
try:
if not user_id and is_hiring_manager(current_user):
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
if user_id and is_hiring_manager(current_user):
if user_id:
await assert_manager_candidate_access(
self.session,current_user,user_id=user_id,
self.session,current_user,user_id=user_id,created_by=created_by,
)
detail=bool(user_id)
# Detail mode must see every application for the candidate, not one page.
fetch_limit=1000 if detail else limit
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search)
list_job_ids=None
if not detail:
list_job_ids=await job_post_ids_for_candidate_list(
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
)
if list_job_ids is not None and not list_job_ids:
return []
rows=await Inbox.get_candidate_profile(
session=self.session,user_id=user_id,limit=limit,offset=offset,search=search,
job_post_ids=list_job_ids,
)
if detail:
records=rows if isinstance(rows,list) else ([rows] if rows else [])
if records and is_hiring_manager(current_user):
owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id")))
owned=await owned_job_ids_for_candidate_scope(self.session,current_user,created_by=created_by)
if records and owned is not None:
owned_set=set(owned)
kept=[]
for rec in records:
msg=getattr(rec,"messages",None)
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
if jid and jid in owned:
if jid and jid in owned_set:
kept.append(rec)
if kept:
return await self.attach_profile_detail(kept)
return await self.attach_application_history(await self.attach_profile_detail(kept))
records=[]
if records:
return await self.attach_profile_detail(rows)
return await self.attach_application_history(await self.attach_profile_detail(rows))
# Manual uploads create users + manual_upload_candidate but no inbox
# row — resolve the profile from that table instead of returning [].
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
if not manual:
return []
if is_hiring_manager(current_user):
owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id")))
if not manual.job_post_id or manual.job_post_id not in owned:
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
if owned is not None:
owned_set=set(owned)
if not manual.job_post_id or manual.job_post_id not in owned_set:
raise HTTPException(status_code=403,detail=_scope_detail(current_user))
user=await Users.get_user_by_id(self.session,user_id)
job_post=None
if manual.job_post_id:
@ -838,15 +945,15 @@ class CandidateView:
payload["candidate_id"]=score.get("candidate_id")
if score.get("user_id") and not payload.get("user_id"):
payload["user_id"]=score["user_id"]
if score.get("job_post_id"):
payload["scored_job_post_id"]=score["job_post_id"]
return payload
if score.get("job_post_id"):
payload["scored_job_post_id"]=score["job_post_id"]
return await self.attach_application_history(payload)
# List mode: inbox applications + manual/form applications (dedupe by user).
inbox_payloads=await self.attach_job_posts(rows)
if not isinstance(inbox_payloads,list):
inbox_payloads=[inbox_payloads] if inbox_payloads else []
manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
self.session,limit=fetch_limit,offset=0,search=search,
self.session,limit=limit,offset=0,search=search,job_post_ids=list_job_ids,
)
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
manual_payloads=[]
@ -897,24 +1004,36 @@ class CandidateView:
if row and row.get("overall_score") is not None:
payload["ai_score"]=row["overall_score"]
payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"])
return inbox_payloads+manual_payloads
data=inbox_payloads+manual_payloads
if assigned_job_post_id:
job_id=str(assigned_job_post_id)
data=[p for p in data if str(p.get("assigned_job_post_id") or "")==job_id]
return await self.attach_application_history(data)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def count_candidates(self,user_id=None,search=None):
async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
try:
return await Inbox.count_candidate_profiles(session=self.session,user_id=user_id,search=search)
job_post_ids=None
if not user_id:
job_post_ids=await job_post_ids_for_candidate_list(
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
)
return await Inbox.count_candidate_profiles(
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def update_candidate(self,user_id,payload,current_user=None):
async def update_candidate(self,user_id,payload,current_user=None,created_by=False):
try:
if not user_id:
raise HTTPException(status_code=400,detail="user_id is required")
await assert_manager_candidate_access(self.session,current_user,user_id=user_id,created_by=created_by)
fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None}
if not fields:
raise HTTPException(status_code=400,detail="favorite or rating is required")
@ -942,7 +1061,7 @@ class CandidateView:
from_value=old_rating,to_value=fields["rating"],commit=True,
)
refreshed=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0)
return await self.attach_profile_detail(refreshed)
return await self.attach_application_history(await self.attach_profile_detail(refreshed))
except HTTPException:
raise
except Exception as e:
@ -1248,3 +1367,117 @@ class CandidateView:
description="cv_bank",commit=True,
)
return serialize_matching_candidate(row,job_post)
async def application_history_by_emails(self,emails):
"""Prior applications keyed by lowercase email across users / inbox /
manual_upload_candidate / form_data / candidates."""
lowers=[]
seen=set()
for raw in emails or []:
email=_norm_email(raw)
if email and email not in seen:
seen.add(email)
lowers.append(email)
if not lowers:
return {}
users=await Users.get_users_by_emails(self.session,lowers)
user_by_email={_norm_email(u.email):u for u in users}
inbox_rows=await Inbox.list_applications_by_emails(self.session,lowers)
manual_rows=await Manual_UPLOAD_CANDIDATE.list_by_emails(self.session,lowers)
form_rows=await FormData.list_by_emails(self.session,lowers)
ats_rows=await Candidates.list_by_emails(self.session,lowers)
packed={email:{"present_in":[],"user":user_by_email.get(email),"applications":[]} for email in lowers}
for email,user in user_by_email.items():
if email in packed:
packed[email]["present_in"].append("users")
packed[email]["user"]=user
for row in inbox_rows:
email=_norm_email(row.get("email"))
if email in packed:
packed[email]["applications"].append(row)
for row in manual_rows:
email=_norm_email(row.get("email"))
if email not in packed:
continue
packed[email]["applications"].append(row)
if "manual_upload_candidate" not in packed[email]["present_in"]:
packed[email]["present_in"].append("manual_upload_candidate")
for row in form_rows:
email=_norm_email(row.get("email"))
if email not in packed:
continue
packed[email]["applications"].append(row)
if "form_data" not in packed[email]["present_in"]:
packed[email]["present_in"].append("form_data")
for row in ats_rows:
email=_norm_email(row.get("email"))
if email not in packed:
continue
packed[email]["applications"].append(row)
if "candidates" not in packed[email]["present_in"]:
packed[email]["present_in"].append("candidates")
pipeline_jobs={}
for email,pack in packed.items():
jobs=set()
for row in pack["applications"]:
if row.get("source") in ("inbox","manual") and row.get("job_post_id"):
jobs.add(row["job_post_id"])
pipeline_jobs[email]=jobs
for email,pack in packed.items():
jobs=pipeline_jobs.get(email) or set()
if not jobs:
continue
# ATS scores for a job the person already applied to are not a
# separate application — they duplicate the pipeline row.
pack["applications"]=[
row for row in pack["applications"]
if not (row.get("source")=="ats" and row.get("job_post_id") in jobs)
]
for pack in packed.values():
pack["applications"].sort(key=lambda r: r.get("applied_at") or "",reverse=True)
present=pack["present_in"]
pack["present_in"]=[name for name in _HISTORY_TABLES if name in present]
return packed
async def get_application_history(self,email):
cleaned=_norm_email(email)
if not cleaned:
raise HTTPException(status_code=422,detail="email is required")
packed=await self.application_history_by_emails([cleaned])
pack=packed.get(cleaned) or {"present_in":[],"user":None,"applications":[]}
return serialize_application_history(
cleaned,user=pack.get("user"),present_in=pack.get("present_in"),
applications=pack.get("applications"),
)
async def attach_application_history(self,payloads):
"""Stamp is_reapplicant + previous_applications onto list/detail dicts."""
single=not isinstance(payloads,list)
records=[payloads] if single else list(payloads or [])
emails=[_payload_email(p) for p in records]
history=await self.application_history_by_emails(emails)
for payload in records:
if not isinstance(payload,dict):
continue
email=_payload_email(payload)
pack=history.get(email) or {"present_in":[],"user":None,"applications":[]}
previous=[]
for row in pack.get("applications") or []:
if _is_current_application(row,payload):
continue
previous.append({
"source":row.get("source"),
"inbox_id":row.get("inbox_id"),
"message_id":row.get("message_id"),
"manual_upload_candidate_id":row.get("manual_upload_candidate_id"),
"form_data_id":row.get("form_data_id"),
"candidate_id":row.get("candidate_id"),
"job_post_id":row.get("job_post_id"),
"job_title":row.get("job_title"),
"status":row.get("status"),
"applied_at":row.get("applied_at"),
})
payload["present_in"]=list(pack.get("present_in") or [])
payload["is_reapplicant"]=bool(previous)
payload["previous_applications"]=previous
return records[0] if single else records

View File

@ -2,7 +2,7 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, JSON, Index, String, case, cast, func, or_, union_all
from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, func, or_, union_all
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from sqlmodel import Field, Relationship, SQLModel, select
@ -148,6 +148,52 @@ class JobPosts(SQLModel, table=True):
result = await session.execute(statement)
return list(result.scalars().all())
@staticmethod
def title_ilike_pattern(applied_for: str) -> str | None:
"""ILIKE pattern so job_posts.title contains the form's Position Applied For."""
needle = (applied_for or "").strip()
if not needle:
return None
escaped = needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{escaped}%"
@classmethod
async def ids_for_title_ilike(cls, session: AsyncSession, applied_for: str) -> list[uuid.UUID]:
"""All non-deleted job_posts.id whose title ILIKE-contains applied_for.
One form title can match many posts. Order is created_at DESC, id DESC
so the UI/ATS list is stable. Empty if blank or no row. Suggested,
not recruiter-assigned.
"""
pattern = cls.title_ilike_pattern(applied_for)
if not pattern:
return []
statement = (
select(cls.id)
.where(cls.is_deleted == False) # noqa: E712
.where(cls.title.ilike(pattern, escape="\\"))
.order_by(cls.created_at.desc(), cls.id.desc())
)
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def ids_for_titles_ilike(
cls, session: AsyncSession, titles: list[str],
) -> dict[str, list[uuid.UUID]]:
"""Map stripped Position Applied For → every matching job_posts.id."""
out: dict[str, list[uuid.UUID]] = {}
seen: set[str] = set()
for raw in titles or []:
key = (raw or "").strip()
if not key or key in seen:
continue
seen.add(key)
found = await cls.ids_for_title_ilike(session, key)
if found:
out[key] = found
return out
@classmethod
async def fetch_job_posts(
cls,
@ -163,6 +209,7 @@ class JobPosts(SQLModel, table=True):
requisition_status: str | None = None,
employment_type: str | None = None,
hiring_manager_id: uuid.UUID | None = None,
restrict_ids: list | None = None,
):
if ids:
rows = await cls.get_by_ids(session, ids, active_only=active_only)
@ -173,6 +220,15 @@ class JobPosts(SQLModel, table=True):
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
elif not include_deleted:
statement = statement.where(cls.is_deleted == False) # noqa: E712
if restrict_ids is not None:
uids = []
for raw in restrict_ids:
uid = raw if isinstance(raw, uuid.UUID) else cls._as_uuid(raw)
if uid is not None:
uids.append(uid)
if not uids:
return [], 0
statement = statement.where(cls.id.in_(uids))
if search:
like = f"%{search.strip()}%"
statement = statement.where(
@ -435,6 +491,33 @@ class JobPosts(SQLModel, table=True):
out.append(row_id)
return out
@classmethod
async def ids_for_creator(cls, session: AsyncSession, user_id, created_by=False):
"""Jobs this recruiter should see on Candidates (when they lack
candidates.manage). created_by=True created_by = session user only.
Otherwise: current_recruiter_id when set, else created_by."""
uid = cls._as_uuid(user_id)
if uid is None:
return []
if created_by:
result = await session.execute(
select(cls.id).where(
cls.created_by == uid,
cls.is_deleted == False, # noqa: E712
)
)
return list(result.scalars().all())
result = await session.execute(
select(cls.id).where(
or_(
and_(cls.current_recruiter_id.is_not(None), cls.current_recruiter_id == uid),
and_(cls.current_recruiter_id.is_(None), cls.created_by == uid),
),
cls.is_deleted == False, # noqa: E712
)
)
return list(result.scalars().all())
@classmethod
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
"""Open requisitions per hiring manager, keyed by users.id."""

View File

@ -223,7 +223,24 @@ class JobPost:
except (httpx.HTTPError,BufferError,RuntimeError) as e:
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e
async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True):
async def _restrict_ids_for_requisition_scope(self,current_user):
"""None = unscoped. Empty list = no jobs. Else owned job-post ids."""
from users.permissions import scopes_to_own_requisitions
if not scopes_to_own_requisitions(current_user):
return None
return await JobPosts.ids_for_manager(self.session,current_user.get("id") if current_user else None)
async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True,current_user=None):
restrict=await self._restrict_ids_for_requisition_scope(current_user)
if restrict is not None:
owned={str(i) for i in restrict}
if ids:
ids=[i for i in ids if str(i) in owned]
if not ids:
return [],0
restrict=None
elif not restrict:
return [],0
rows,total=await JobPosts.fetch_job_posts(
self.session,
search=search,
@ -231,6 +248,7 @@ class JobPost:
skip=skip,
ids=ids,
active_only=active_only,
restrict_ids=restrict,
)
return [serialize_job_post(r) for r in rows],total
@ -274,7 +292,11 @@ class JobPost:
]
async def fetch_jobs(self,search=None,department=None,requisition_status=None,
employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True):
employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True,
current_user=None):
restrict=await self._restrict_ids_for_requisition_scope(current_user)
if restrict is not None and not restrict:
return [],0
hm_uid=None
if hiring_manager_id:
hm_uid=JobPosts._as_uuid(hiring_manager_id)
@ -284,6 +306,7 @@ class JobPost:
self.session,search=search,top=top,skip=skip,active_only=active_only,
department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hm_uid,
restrict_ids=restrict,
)
names=await Users.names_by_ids(
self.session,

View File

@ -15,13 +15,13 @@ class Note:
async def _load(self,record_id):
return await Notes.get_note_by_id(self.session,record_id)
async def get_note(self,note_id=None,user_id=None,current_user=None):
async def get_note(self,note_id=None,user_id=None,current_user=None,created_by=False):
if note_id:
row=await self._load(note_id)
if not row:
raise HTTPException(status_code=404,detail="Note not found")
await assert_manager_candidate_access(
self.session,current_user,user_id=row.user_id,
self.session,current_user,user_id=row.user_id,created_by=created_by,
)
return serialize_note(row)
if not user_id:
@ -29,11 +29,11 @@ class Note:
uid=Notes._as_uuid(user_id)
if uid is None:
raise HTTPException(status_code=400,detail="Invalid user_id")
await assert_manager_candidate_access(self.session,current_user,user_id=uid)
await assert_manager_candidate_access(self.session,current_user,user_id=uid,created_by=created_by)
rows=await Notes.get_notes_by_user(self.session,uid)
return [serialize_note(r) for r in rows]
async def create_note(self,payload,current_user):
async def create_note(self,payload,current_user,created_by=False):
fields={
"note":payload.get("note") or "",
"user_id":payload.get("user_id"),
@ -42,7 +42,7 @@ class Note:
if not fields["user_id"]:
raise HTTPException(status_code=400,detail="user_id is required")
await assert_manager_candidate_access(
self.session,current_user,user_id=fields["user_id"],
self.session,current_user,user_id=fields["user_id"],created_by=created_by,
)
row=await Notes.insert_note(self.session,fields)
await HistoryRecorder(self.session).record(
@ -54,7 +54,7 @@ class Note:
row=await self._load(row.id)
return serialize_note(row)
async def update_note(self,note_id,payload,current_user=None):
async def update_note(self,note_id,payload,current_user=None,created_by=False):
fields={k:v for k,v in payload.items() if v is not None and k in ("note",)}
if not fields:
raise HTTPException(status_code=400,detail="No fields to update")
@ -62,7 +62,7 @@ class Note:
if not before:
raise HTTPException(status_code=404,detail="Note not found")
await assert_manager_candidate_access(
self.session,current_user,user_id=before.user_id,
self.session,current_user,user_id=before.user_id,created_by=created_by,
)
old_note=before.note or ""
row=await Notes.update_note(self.session,note_id,fields)

View File

@ -21,6 +21,10 @@ class Pipeline:
try:
inbox_data=await Inbox.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset)
manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset)
from job.candidate.views import CandidateView
history=CandidateView(session=self.session)
inbox_data=await history.attach_application_history(inbox_data)
manual_upload_data=await history.attach_application_history(manual_upload_data)
counts=serialize_pipeline_counts(
await Inbox.count_by_status(self.session,job_post_id=job_post_id),
await Manual_UPLOAD_CANDIDATE.count_by_status(self.session,job_post_id=job_post_id),

View File

@ -0,0 +1,53 @@
-- 028: Slim bundles for custom Access Control roles that should see only the
-- Requisitions and Interviews/Calendar tabs. The tags already exist (001, 019);
-- the seeded bundles are too wide — requisitions_management includes
-- requisitions.manage (org-wide list), analytics_dashboard hangs interviews.view
-- off dashboard/analytics/offers, hiring_forms has interview writes but no view.
--
-- These two are NOT attached to seeded staff roles (those already have the wide
-- bundles). Admins tick them on a new role in Access Control.
-- Applied at startup by alembic_setup.run_manual_sql(). Log in again after.
-- =============================================================================
-- 1. Own requisitions only (omit .manage so is_admin() stays false)
-- =============================================================================
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
SELECT
'requisitions_self',
'Own employee requisition forms: view, create, edit (not org-wide manage)',
(
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
FROM app.permission_tags
WHERE is_deleted = false
AND tag_name IN ('requisitions.view', 'requisitions.create', 'requisitions.edit')
),
true,
NOW(),
NOW(),
true,
false
WHERE NOT EXISTS (
SELECT 1 FROM app.permissions WHERE name = 'requisitions_self'
);
-- =============================================================================
-- 2. Interviews / Calendar tab (view + schedule + amend)
-- =============================================================================
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
SELECT
'interviews_tab',
'Interviews and Calendar tabs: list, schedule, reschedule',
(
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
FROM app.permission_tags
WHERE is_deleted = false
AND tag_name IN ('interviews.view', 'interviews.create', 'interviews.edit')
),
true,
NOW(),
NOW(),
true,
false
WHERE NOT EXISTS (
SELECT 1 FROM app.permissions WHERE name = 'interviews_tab'
);

View File

@ -6,7 +6,7 @@ from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from db_setup import get_session
from org_settings.views import OrgSetting
from org_settings.views import Exclusion, OrgSetting
from users.permissions import PermissionTag, require_permission
router = APIRouter()
@ -52,3 +52,153 @@ async def update_org_settings(
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
class ExcludeUniversityCreate(BaseModel):
name: str
class ExcludeUniversityUpdate(BaseModel):
name: str | None = None
class ExcludeCompanyCreate(BaseModel):
name: str
linkedin_url: str | None = None
class ExcludeCompanyUpdate(BaseModel):
name: str | None = None
linkedin_url: str | None = None
@router.get("/org-settings/exclude-university/fetch")
async def fetch_exclude_universities(
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service = Exclusion(session=session)
data, total = await service.get_universities()
return JSONResponse(content={"data": data, "total": total, "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/org-settings/exclude-university/create")
async def create_exclude_university(
payload: ExcludeUniversityCreate,
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
session: AsyncSession = Depends(get_session),
):
try:
service = Exclusion(session=session)
data = await service.create_university(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.patch("/org-settings/exclude-university/update")
async def update_exclude_university(
payload: ExcludeUniversityUpdate,
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
record_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
try:
service = Exclusion(session=session)
data = await service.update_university(
record_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("/org-settings/exclude-university/delete")
async def delete_exclude_university(
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
record_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
try:
service = Exclusion(session=session)
data = await service.delete_university(record_id, 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.get("/org-settings/exclude-company/fetch")
async def fetch_exclude_companies(
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service = Exclusion(session=session)
data, total = await service.get_companies()
return JSONResponse(content={"data": data, "total": total, "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/org-settings/exclude-company/create")
async def create_exclude_company(
payload: ExcludeCompanyCreate,
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
session: AsyncSession = Depends(get_session),
):
try:
service = Exclusion(session=session)
data = await service.create_company(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.patch("/org-settings/exclude-company/update")
async def update_exclude_company(
payload: ExcludeCompanyUpdate,
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
record_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
try:
service = Exclusion(session=session)
data = await service.update_company(
record_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("/org-settings/exclude-company/delete")
async def delete_exclude_company(
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
record_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
try:
service = Exclusion(session=session)
data = await service.delete_company(record_id, 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))

View File

@ -3,7 +3,7 @@ from typing import Any
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, JSON
from sqlalchemy import DateTime, JSON, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
@ -76,4 +76,188 @@ class OrgSettings(SQLModel, table=True):
return rows
class ExcludeUniversity(SQLModel, table=True):
__tablename__ = "exclude_university"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
name: str = Field(index=True)
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_deleted: bool = Field(default=False)
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(
select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
)
return result.scalars().first()
@classmethod
async def get_by_name(cls, session: AsyncSession, name: str, *, exclude_id=None):
cleaned = (name or "").strip()
if not cleaned:
return None
statement = select(cls).where(
func.lower(cls.name) == cleaned.lower(),
cls.is_deleted == False, # noqa: E712
)
if exclude_id is not None:
uid = cls._as_uuid(exclude_id)
if uid is not None:
statement = statement.where(cls.id != uid)
result = await session.execute(statement)
return result.scalars().first()
@classmethod
async def fetch_rows(cls, session: AsyncSession):
statement = select(cls).where(cls.is_deleted == False).order_by(cls.name.asc()) # noqa: E712
result = await session.execute(statement)
rows = list(result.scalars().all())
return rows, len(rows)
@classmethod
async def fetch_names(cls, session: AsyncSession) -> list[str]:
rows, _ = await cls.fetch_rows(session)
return [r.name for r in rows if r.name]
@classmethod
async def insert_row(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_by_id(session, row.id)
@classmethod
async def update_row(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_by_id(session, record_id)
if not row:
return None
for key, value in fields.items():
setattr(row, key, value)
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def soft_delete_row(cls, session: AsyncSession, record_id):
row = await cls.get_by_id(session, record_id)
if not row:
return None
row.is_deleted = True
row.updated_at = _now()
session.add(row)
await session.commit()
return row
class ExcludeCompany(SQLModel, table=True):
__tablename__ = "exclude_company"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
name: str = Field(index=True)
linkedin_url: str | None = Field(default=None)
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_deleted: bool = Field(default=False)
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(
select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
)
return result.scalars().first()
@classmethod
async def get_by_name(cls, session: AsyncSession, name: str, *, exclude_id=None):
cleaned = (name or "").strip()
if not cleaned:
return None
statement = select(cls).where(
func.lower(cls.name) == cleaned.lower(),
cls.is_deleted == False, # noqa: E712
)
if exclude_id is not None:
uid = cls._as_uuid(exclude_id)
if uid is not None:
statement = statement.where(cls.id != uid)
result = await session.execute(statement)
return result.scalars().first()
@classmethod
async def fetch_rows(cls, session: AsyncSession):
statement = select(cls).where(cls.is_deleted == False).order_by(cls.name.asc()) # noqa: E712
result = await session.execute(statement)
rows = list(result.scalars().all())
return rows, len(rows)
@classmethod
async def fetch_names(cls, session: AsyncSession) -> list[str]:
rows, _ = await cls.fetch_rows(session)
return [r.name for r in rows if r.name]
@classmethod
async def fetch_linkedin_urls(cls, session: AsyncSession) -> list[str]:
rows, _ = await cls.fetch_rows(session)
return [r.linkedin_url for r in rows if r.linkedin_url]
@classmethod
async def insert_row(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_by_id(session, row.id)
@classmethod
async def update_row(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_by_id(session, record_id)
if not row:
return None
for key, value in fields.items():
setattr(row, key, value)
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def soft_delete_row(cls, session: AsyncSession, record_id):
row = await cls.get_by_id(session, record_id)
if not row:
return None
row.is_deleted = True
row.updated_at = _now()
session.add(row)
await session.commit()
return row
import users.models as _users_models # noqa: E402, F401

View File

@ -5,3 +5,22 @@ def serialize_org_setting(row) -> dict:
"category": row.category,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def serialize_exclude_university(row) -> dict:
return {
"id": str(row.id) if row.id else None,
"name": row.name,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def serialize_exclude_company(row) -> dict:
return {
"id": str(row.id) if row.id else None,
"name": row.name,
"linkedin_url": row.linkedin_url,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}

View File

@ -3,8 +3,12 @@ import uuid
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from org_settings.models import OrgSettings
from org_settings.serializers import serialize_org_setting
from org_settings.models import ExcludeCompany, ExcludeUniversity, OrgSettings
from org_settings.serializers import (
serialize_exclude_company,
serialize_exclude_university,
serialize_org_setting,
)
VALID_CATEGORIES = (
"general",
@ -70,3 +74,111 @@ class OrgSetting:
})
rows = await OrgSettings.upsert_settings(self.session, cleaned, _user_id(current_user))
return [serialize_org_setting(r) for r in rows]
MAX_NAME_LEN = 200
MAX_URL_LEN = 500
def _clean_name(value, *, label="name"):
name = (value or "").strip()
if not name:
raise HTTPException(status_code=422, detail=f"{label} is required")
if len(name) > MAX_NAME_LEN:
raise HTTPException(status_code=422, detail=f"{label} must be {MAX_NAME_LEN} characters or fewer")
return name
def _clean_linkedin_url(value):
url = (value or "").strip() or None
if url is None:
return None
if len(url) > MAX_URL_LEN:
raise HTTPException(status_code=422, detail=f"linkedin_url must be {MAX_URL_LEN} characters or fewer")
lowered = url.lower()
if not (lowered.startswith("http://") or lowered.startswith("https://")):
raise HTTPException(status_code=422, detail="linkedin_url must be an http(s) URL")
return url
class Exclusion:
def __init__(self, session: AsyncSession):
self.session = session
async def get_universities(self):
rows, total = await ExcludeUniversity.fetch_rows(self.session)
return [serialize_exclude_university(r) for r in rows], total
async def create_university(self, payload, current_user):
name = _clean_name(payload.get("name"), label="name")
if await ExcludeUniversity.get_by_name(self.session, name):
raise HTTPException(status_code=409, detail="That university is already excluded")
row = await ExcludeUniversity.insert_row(self.session, {
"name": name,
"created_by": _user_id(current_user),
})
return serialize_exclude_university(row)
async def update_university(self, record_id, payload, current_user):
_user_id(current_user)
fields = {}
if "name" in payload:
name = _clean_name(payload.get("name"), label="name")
existing = await ExcludeUniversity.get_by_name(self.session, name, exclude_id=record_id)
if existing:
raise HTTPException(status_code=409, detail="That university is already excluded")
fields["name"] = name
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
row = await ExcludeUniversity.update_row(self.session, record_id, fields)
if not row:
raise HTTPException(status_code=404, detail="Excluded university not found")
return serialize_exclude_university(row)
async def delete_university(self, record_id, current_user):
_user_id(current_user)
row = await ExcludeUniversity.soft_delete_row(self.session, record_id)
if not row:
raise HTTPException(status_code=404, detail="Excluded university not found")
return {"id": str(row.id), "deleted": True}
async def get_companies(self):
rows, total = await ExcludeCompany.fetch_rows(self.session)
return [serialize_exclude_company(r) for r in rows], total
async def create_company(self, payload, current_user):
name = _clean_name(payload.get("name"), label="name")
linkedin_url = _clean_linkedin_url(payload.get("linkedin_url"))
if await ExcludeCompany.get_by_name(self.session, name):
raise HTTPException(status_code=409, detail="That company is already excluded")
row = await ExcludeCompany.insert_row(self.session, {
"name": name,
"linkedin_url": linkedin_url,
"created_by": _user_id(current_user),
})
return serialize_exclude_company(row)
async def update_company(self, record_id, payload, current_user):
_user_id(current_user)
fields = {}
if "name" in payload:
name = _clean_name(payload.get("name"), label="name")
existing = await ExcludeCompany.get_by_name(self.session, name, exclude_id=record_id)
if existing:
raise HTTPException(status_code=409, detail="That company is already excluded")
fields["name"] = name
if "linkedin_url" in payload:
fields["linkedin_url"] = _clean_linkedin_url(payload.get("linkedin_url"))
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
row = await ExcludeCompany.update_row(self.session, record_id, fields)
if not row:
raise HTTPException(status_code=404, detail="Excluded company not found")
return serialize_exclude_company(row)
async def delete_company(self, record_id, current_user):
_user_id(current_user)
row = await ExcludeCompany.soft_delete_row(self.session, record_id)
if not row:
raise HTTPException(status_code=404, detail="Excluded company not found")
return {"id": str(row.id), "deleted": True}

View File

@ -48,6 +48,11 @@ class RolePermissionTagsUpdate(BaseModel):
is_active: bool | None = None
class RoleMatrixUpdate(BaseModel):
"""Exact tag ids for one role. Saved onto that role's overlay bundle."""
permission_tags: list[int]
@router.get("/roles/fetch")
async def fetch_roles(
current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)),
@ -174,6 +179,23 @@ async def update_permission(
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.put("/roles/matrix/update")
async def update_role_matrix(
payload: RoleMatrixUpdate,
current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_EDIT)),
record_id: int = Query(...),
session: AsyncSession = Depends(get_session),
):
try:
service=Role(session=session)
data=await service.set_role_matrix(record_id,payload.permission_tags)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.put("/roles/permission-tags/update")
async def update_role_permission_tags(
payload: RolePermissionTagsUpdate,

View File

@ -81,6 +81,46 @@ class Role:
updated = await Roles.update_role(self.session, int(record_id), fields)
return await self._role_payload(updated)
async def set_role_matrix(self, record_id, permission_tags):
"""Write the Access Control grid onto one overlay bundle for this role.
Shared system bundles are not mutated. The role then points at that
overlay only, so a ticked cell is the grant and an unticked cell is not.
"""
role = await Roles.get_role_by_id(self.session, int(record_id))
if not role or role.is_deleted:
raise HTTPException(status_code=404, detail="Role not found")
tag_ids = sorted({int(i) for i in (permission_tags or [])})
found = await PermissionTags.get_permission_tags_by_ids(self.session, tag_ids)
unknown = sorted(set(tag_ids) - {t.id for t in found})
if unknown:
raise HTTPException(
status_code=422,
detail=f"Unknown or inactive permission tag ids: {unknown}",
)
overlay_name = f"role_{role.id}_matrix"
bundle = await Permissions.get_permission_by_name(self.session, overlay_name)
if bundle is None:
bundle = await Permissions.insert_permission(
self.session,
{
"name": overlay_name,
"description": f"Access Control matrix for {role.role_name}",
"permission_tags": tag_ids,
"is_system": False,
"is_active": True,
"is_deleted": False,
},
)
else:
await Permissions.update_permission(
self.session, int(bundle.id), {"permission_tags": tag_ids},
)
updated = await Roles.update_role(
self.session, int(record_id), {"permissions": [int(bundle.id)]},
)
return await self._role_payload(updated)
async def delete_role(self, record_id):
role = await Roles.get_role_by_id(self.session, int(record_id))
if not role or role.is_deleted:

View File

@ -35,41 +35,32 @@ APIFY_TIMEOUT = float(os.getenv("APIFY_TIMEOUT", "30"))
APIFY_MAX_COST_USD = float(os.getenv("APIFY_MAX_COST_USD", "1.0"))
def _csv_env(name: str, default: str) -> list[str]:
return [s.strip() for s in os.getenv(name, default).split(",") if s.strip()]
# The user's own companies: their CURRENT employees must never appear in sourced
# results. Names drive the always-on server-side filter (case-insensitive
# substring, so "Utopia Brands Pakistan" matches too). URLs drive the actor's
# excludeCurrentCompanies filter, which wants full LinkedIn company URLs and
# stops those profiles from being scraped (and paid for) at all.
APIFY_EXCLUDE_COMPANIES = _csv_env("APIFY_EXCLUDE_COMPANIES", "Utopia Brands,Utopia Deals")
APIFY_EXCLUDE_COMPANY_URLS = _csv_env(
"APIFY_EXCLUDE_COMPANY_URLS",
"https://www.linkedin.com/company/utopiadeals,"
"https://www.linkedin.com/company/utopia-brands-usa,"
"https://www.linkedin.com/company/utopiabrands",
)
def _matches_excluded(text) -> bool:
def _matches_excluded(text, names) -> bool:
haystack = " ".join(str(text or "").lower().split())
return bool(haystack) and any(
name.lower() in haystack for name in APIFY_EXCLUDE_COMPANIES
)
if not haystack:
return False
return any(str(name).lower() in haystack for name in (names or []) if name)
def is_excluded_profile(profile: dict) -> bool:
"""True when the person currently works at one of the excluded companies.
def is_excluded_profile(profile: dict, *, companies=None, universities=None) -> bool:
"""True when the person matches a configured company or university exclusion.
The headline is only consulted when no current company was extracted, so an
"ex-Utopia" headline on someone now elsewhere does not exclude them.
Company: current employer, or headline only when no company was extracted so
an "ex-…" headline on someone now elsewhere does not exclude them.
University: any education school name on the sourced profile.
Lists come from exclude_company / exclude_university never hardcoded.
"""
company = (profile or {}).get("current_company")
if _matches_excluded(company):
if _matches_excluded(company, companies):
return True
return not company and _matches_excluded((profile or {}).get("headline"))
if not company and _matches_excluded((profile or {}).get("headline"), companies):
return True
if universities:
raw = (profile or {}).get("raw") or {}
for edu in extract_education(raw):
if _matches_excluded(edu.get("school"), universities):
return True
return False
# Apify run status -> talent_runs.status. Transitional states stay "running";
# unknown values also stay "running" so we never commit a terminal state we
@ -189,7 +180,12 @@ def _skill_terms(*entry_lists) -> list[str]:
def build_actor_input(
job: dict, *, max_results: int, overrides: dict | None = None, start_page: int = 1
job: dict,
*,
max_results: int,
overrides: dict | None = None,
start_page: int = 1,
exclude_company_urls: list[str] | None = None,
) -> dict:
"""Deterministic actor input from job fields. No LLM involved.
@ -225,8 +221,9 @@ def build_actor_input(
)
if experience_ids:
actor_input["yearsOfExperienceIds"] = experience_ids
if APIFY_EXCLUDE_COMPANY_URLS:
actor_input["excludeCurrentCompanies"] = APIFY_EXCLUDE_COMPANY_URLS
urls = [u.strip() for u in (exclude_company_urls or []) if u and str(u).strip()]
if urls:
actor_input["excludeCurrentCompanies"] = urls
if start_page and int(start_page) > 1:
actor_input["startPage"] = min(int(start_page), 100)
if "location" in overrides and overrides["location"] is not None:

View File

@ -3,6 +3,7 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from job.job_post.models import JobPosts
from org_settings.models import ExcludeCompany, ExcludeUniversity
from talent import plugins
from talent.enums import OutreachStatus
from talent.matching import annotate_applications
@ -24,6 +25,13 @@ class Talent:
def __init__(self, session: AsyncSession):
self.session = session
async def _exclusion_lists(self):
"""Names and LinkedIn URLs from Settings — empty lists mean exclude nothing."""
company_names = await ExcludeCompany.fetch_names(self.session)
company_urls = await ExcludeCompany.fetch_linkedin_urls(self.session)
university_names = await ExcludeUniversity.fetch_names(self.session)
return company_names, company_urls, university_names
async def _get_job(self, job_post_id):
job = await JobPosts.get_job_post_by_id(self.session, job_post_id)
if not job or job.is_deleted:
@ -60,8 +68,12 @@ class Talent:
"experience_min": job.experience_min,
"experience_max": job.experience_max,
}
_company_names, company_urls, _university_names = await self._exclusion_lists()
actor_input = plugins.build_actor_input(
job_fields, max_results=max_results, overrides=overrides
job_fields,
max_results=max_results,
overrides=overrides,
exclude_company_urls=company_urls,
)
# Re-running the same search continues deeper into LinkedIn's result
@ -80,6 +92,7 @@ class Talent:
max_results=max_results,
overrides=overrides,
start_page=max(prior_pages) + 1,
exclude_company_urls=company_urls,
)
run = await TalentRuns.insert_run(self.session, {
"job_post_id": job.id,
@ -149,10 +162,13 @@ class Talent:
items = await plugins.get_dataset_items(dataset_id, limit=run.max_results)
except (httpx.HTTPError, plugins.ApifyError, RuntimeError) as exc:
raise HTTPException(status_code=502, detail=f"Apify dataset fetch failed: {exc}")
company_names, _company_urls, university_names = await self._exclusion_lists()
normalized = [
p
for p in (plugins.normalize_profile(i) for i in items)
if p and not plugins.is_excluded_profile(p)
if p and not plugins.is_excluded_profile(
p, companies=company_names, universities=university_names
)
]
job = await JobPosts.get_job_post_by_id(self.session, run.job_post_id)
if job:

View File

@ -151,3 +151,94 @@ def test_rows_to_indexed_records_keeps_true_sheet_row_across_blank():
def test_form_data_fields_match_model():
assert set(FORM_DATA_FIELDS) == set(FormData.model_fields)
def test_drive_file_id_from_form_open_url():
url = "https://drive.google.com/open?id=1l5HOY5R6KiL6270A_sV56FkdX6EIGfI4"
assert plugins.drive_file_id(url) == "1l5HOY5R6KiL6270A_sV56FkdX6EIGfI4"
def test_drive_file_id_from_file_view_url():
url = "https://drive.google.com/file/d/abc123XYZ/view?usp=sharing"
assert plugins.drive_file_id(url) == "abc123XYZ"
def test_drive_file_id_from_docs_url():
url = "https://docs.google.com/document/d/docFileId99/edit"
assert plugins.drive_file_id(url) == "docFileId99"
def test_drive_file_id_rejects_folder_and_non_drive():
assert plugins.drive_file_id("https://drive.google.com/drive/folders/abc") is None
assert plugins.drive_file_id("https://example.com/cv.pdf") is None
assert plugins.drive_file_id("") is None
assert plugins.drive_file_id(None) is None
def test_cv_dest_path_stays_inside_dir(tmp_path):
dest = plugins._cv_dest_path(tmp_path, "1l5HOY5R6KiL6270A_sV56FkdX6EIGfI4", "Resume.PDF")
assert dest.parent == tmp_path.resolve()
assert dest.name.endswith(".pdf")
assert dest.name.startswith("1l5HOY5R6KiL6270A_sV56FkdX6EIGfI4")
def test_title_ilike_pattern_contains_and_escapes():
from job.job_post.models import JobPosts
assert JobPosts.title_ilike_pattern("") is None
assert JobPosts.title_ilike_pattern(" ") is None
assert JobPosts.title_ilike_pattern("Brand Manager") == "%Brand Manager%"
assert JobPosts.title_ilike_pattern("C++ _dev%") == r"%C++ \_dev\%%"
async def test_stamp_suggested_job_posts_sets_ids_or_empty(monkeypatch):
import uuid
job_a = uuid.uuid4()
job_b = uuid.uuid4()
async def fake_ids(session, titles):
return {"Executive Secretary": [job_a, job_b]}
monkeypatch.setattr(
"job.job_post.models.JobPosts.ids_for_titles_ilike",
fake_ids,
)
records = [
{"position_applied_for": "Executive Secretary"},
{"position_applied_for": "Unknown Role"},
{"position_applied_for": None},
{"position_applied_for": " "},
]
out = await FormData.stamp_suggested_job_posts(object(), records)
assert out[0]["suggested_job_post_ids"] == [str(job_a), str(job_b)]
assert out[0]["job_post_id"] is None
assert out[0]["assigned_job_post_id"] is None
assert out[1]["suggested_job_post_ids"] == []
assert out[1]["job_post_id"] is None
assert out[1]["assigned_job_post_id"] is None
assert out[2]["suggested_job_post_ids"] == []
assert out[3]["suggested_job_post_ids"] == []
def test_score_job_ids_assigned_wins_else_suggested():
import uuid
a = uuid.uuid4()
b = uuid.uuid4()
assigned = uuid.uuid4()
assert FormData.score_job_ids({
"suggested_job_post_ids": [str(a), str(b)],
"assigned_job_post_id": assigned,
}) == [assigned]
assert FormData.score_job_ids({
"suggested_job_post_ids": [str(a), str(b)],
"job_post_id": assigned,
}) == [assigned]
assert FormData.score_job_ids({
"suggested_job_post_ids": [str(a), str(b), str(a)],
"assigned_job_post_id": None,
"job_post_id": None,
}) == [a, b]
assert FormData.score_job_ids({"suggested_job_post_ids": None}) == []
assert FormData.score_job_ids(None) == []

View File

@ -181,6 +181,19 @@ class Users(SQLModel, table=True):
result = await session.execute(statement)
return result.scalars().first()
@classmethod
async def get_users_by_emails(cls, session: AsyncSession, emails):
"""Any account matching these addresses, including candidate role_id=8."""
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
if not lowers:
return []
statement = (
select(cls)
.where(func.lower(cls.email).in_(lowers), cls.is_deleted == False) # noqa: E712
)
result = await session.execute(statement)
return list(result.scalars().all())
@classmethod
async def count_users(cls, session: AsyncSession, search: str | None = None, role_id: Optional[int] = None):
statement = (

View File

@ -234,6 +234,38 @@ def is_admin(current_user: dict | None) -> bool:
return PermissionTag.REQUISITIONS_MANAGE.value in granted
def sees_all_candidates(current_user: dict | None) -> bool:
"""Unscoped Candidates list: admins, or any role granted candidates.manage.
Absence of that tag (with candidates.view) scopes the list to jobs the
user created. Do not key this off role_id a custom role must be able
to opt in through Access Control.
"""
if is_admin(current_user):
return True
granted = (current_user or {}).get("permissions") or []
return PermissionTag.CANDIDATES_MANAGE.value in granted
def scopes_to_own_requisitions(current_user: dict | None) -> bool:
"""Jobs and candidates limited to requisitions this user created (or is assigned).
Opt-in from Access Control: tick Requisitions Configure
(`requisitions.configure`). That is independent of Create (who may open a
requisition) and of Manage (which already means admin and unscopes).
Hiring-manager portal roles still use this scope so the locked sidebar
keeps a matching candidate list. candidates.manage / admin still see every
job. Do not key custom roles off a name such as AI_TEAM_MANAGER.
"""
if is_hiring_manager(current_user):
return True
if is_admin(current_user) or sees_all_candidates(current_user):
return False
granted = (current_user or {}).get("permissions") or []
return PermissionTag.REQUISITIONS_CONFIGURE.value in granted
def has_permission(
granted: set[str] | list[str] | tuple[str, ...],
*required: PermissionTag,

View File

@ -1,4 +1,5 @@
from fastapi import HTTPException
from inbox.models import Inbox
from notifications.views import Confirmation
from role.models import EnumRoles,Roles
from users.models import Users
@ -63,11 +64,14 @@ class User:
await service.send_confirmation(user)
return user
async def get_users(self,top:Optional[int]=None,skip:Optional[int]=None,search:Optional[str]=None,role_id:Optional[int]=None):
async def get_users(self,top:Optional[int]=None,skip:Optional[int]=None,search:Optional[str]=None,role_id:Optional[int]=None,assigned_job_post_id:Optional[str]=None):
if role_id:
users=await Users.get_users(self.session,top=top,skip=skip,search=search,role_id=role_id)
else:
users=await Users.get_users(self.session,top=top,skip=skip,search=search)
if assigned_job_post_id:
linked=await Inbox.get_users_by_job_post_id(self.session,assigned_job_post_id)
users=[u for u in users if u.id in linked]
return [serialize_user(u) for u in users]
async def get_user_by_id(self,record_id):
@ -136,8 +140,12 @@ class User:
]
return data,len(data)
async def count_users(self,search=None,role_id=None):
return await Users.count_users(self.session,search,role_id=role_id)
async def count_users(self,search=None,role_id=None,assigned_job_post_id=None):
if not assigned_job_post_id:
return await Users.count_users(self.session,search,role_id=role_id)
users=await Users.get_users(self.session,search=search,role_id=role_id)
linked=await Inbox.get_users_by_job_post_id(self.session,assigned_job_post_id)
return sum(1 for u in users if u.id in linked)
async def authenticate_user(self,email,password):
user=await Users.get_user_by_email(self.session,email)

View File

@ -0,0 +1,81 @@
/**
* scopesToOwnRequisitions mirrors backend users.permissions.scopes_to_own_requisitions.
*
* node permissions-scope.test.mjs
*/
import {
isAdmin,
isHiringManager,
seesAllCandidates,
scopesToOwnRequisitions,
} from './src/auth/permissions.js'
let failed = 0
function ok(name, cond, extra) {
if (cond) {
console.log(`ok ${name}`)
if (extra) console.log(` ${extra}`)
} else {
failed += 1
console.log(`FAIL ${name}`)
if (extra) console.log(` ${extra}`)
}
}
const recruiter = {
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
role_name: 'recruiter',
permissions: ['candidates.view', 'jobs.view'],
}
ok('recruiter is not requisition-scoped', !scopesToOwnRequisitions(recruiter))
ok('recruiter does not see all candidates', !seesAllCandidates(recruiter))
const custom = {
...recruiter,
role_name: 'AI_TEAM_MANAGER',
permissions: ['candidates.view', 'jobs.view', 'requisitions.create'],
}
ok('requisitions.create alone does not scope jobs/candidates', !scopesToOwnRequisitions(custom))
ok('custom role is not hiring-manager portal', !isHiringManager(custom))
ok('custom role is not admin', !isAdmin(custom))
ok(
'Access Control requisitions.configure enables the scope',
scopesToOwnRequisitions({
...custom,
permissions: ['candidates.view', 'jobs.view', 'requisitions.create', 'requisitions.configure'],
}),
)
const manage = {
...custom,
permissions: ['candidates.view', 'candidates.manage', 'requisitions.configure'],
}
ok(
'candidates.manage wins over requisitions.configure',
!scopesToOwnRequisitions(manage) && seesAllCandidates(manage),
)
ok(
'admin is not requisition-scoped',
!scopesToOwnRequisitions({ role_name: 'admin', permissions: ['requisitions.create'] }) &&
isAdmin({ role_name: 'admin' }),
)
ok(
'hiring_manager stays portal-locked and requisition-scoped',
isHiringManager({ role_name: 'hiring_manager' }) &&
scopesToOwnRequisitions({ role_name: 'hiring_manager', permissions: ['candidates.view'] }),
)
ok(
'requisitions.manage is admin, not this scope',
isAdmin({ role_name: 'ops_lead', permissions: ['requisitions.manage'] }) &&
!scopesToOwnRequisitions({ role_name: 'ops_lead', permissions: ['requisitions.manage'] }),
)
if (failed) {
console.log(`\n${failed} failed`)
process.exit(1)
}
console.log('\nall passed')

View File

@ -1,4 +1,5 @@
import { request } from '../lib/apiClient'
import { toDate } from '../lib/format'
/** Assessments — backend/assessments/app.py. Dual-key: exactly one of inbox_id / manual_upload_candidate_id. */
@ -89,9 +90,9 @@ export function toAssessmentView(row) {
sectionScores: Array.isArray(row.section_scores) ? row.section_scores : [],
duration: durationLabel(row.duration_minutes) || '—',
durationMinutes: row.duration_minutes,
assigned: row.assigned_at ? new Date(row.assigned_at) : null,
due: row.due_at ? new Date(row.due_at) : null,
completedAt: row.completed_at ? new Date(row.completed_at) : null,
remindedAt: row.reminded_at ? new Date(row.reminded_at) : null,
assigned: toDate(row.assigned_at),
due: toDate(row.due_at),
completedAt: toDate(row.completed_at),
remindedAt: toDate(row.reminded_at),
}
}

View File

@ -1,4 +1,5 @@
import { request } from '../lib/apiClient'
import { toDate } from '../lib/format'
/* ============================================================
assignments.js who owns a requisition, and who owns an application.
@ -70,8 +71,8 @@ export function toAssignmentView(row, namesById) {
role: row.assignment_role || 'primary_recruiter',
jobPostId: row.job_post_id ?? null,
inboxId: row.inbox_id ?? null,
validFrom: row.valid_from ? new Date(row.valid_from) : null,
validTo: row.valid_to ? new Date(row.valid_to) : null,
validFrom: toDate(row.valid_from),
validTo: toDate(row.valid_to),
assignedBy: row.assigned_by ?? null,
}
}

View File

@ -1,18 +1,7 @@
/* ============================================================
candidates.js candidate endpoints (backend/job/app.py).
Two data families share this module:
- ATS scoring (persisted `candidates` table): listJobs, listCandidates,
getCandidate, scoreUploads, scoreInbox, toCandidateView.
- Candidate profiles (inbox -> users -> roles join): list, getByUserId,
toRows.
Same conventions as inbox.js: one named export per endpoint, no hooks,
camelCase params mapped to snake_case at the call boundary, and every
function returns the parsed {data, total, status_code} envelope.
============================================================ */
import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
import { toDate } from '../lib/format'
import { STAGE_FROM_STATUS, STATUS_FROM_STAGE } from './pipeline'
/** Active job posts for pickers. Needs job_board.view OR candidates.view.
@ -158,49 +147,27 @@ export function toCandidateView(row) {
scoringStatus: row.status, // 'completed' | 'failed'
errorCode: row.error_code ?? null,
errorMessage: row.error_message ?? null,
applied: row.created_at ? new Date(row.created_at) : null,
applied: toDate(row.created_at),
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
}
/**
* Candidate USER accounts `users` rows filtered by role, not the scored
* `candidates` table. Needs candidates.view.
*
* role_id 8 is the seeded `candidate` role (id 4 is hiring_manager, the signup
* default). We send it explicitly so a missing param cannot list the wrong people.
*
* Three things this route does NOT do, all verified against
* backend/job/app.py::fetch_users:
* - it returns `{data, status_code}` with NO `total` on the list; use
* GET /candidate/fetch/users/count (once on page open) for the pager total;
* - `top`/`skip` page the list; the Candidates screen sends the user's page
* size as `top` and `(page-1)*top` as `skip`;
* - it accepts a `search` query param but never forwards it to the service
* layer (`get_users(role_id=, top=, skip=)`), so searching is a no-op
* server-side. Filtering stays client-side on the fetched page until that
* is fixed.
*/
export function listCandidateUsers({ roleId = 8, top = 10, skip = 0 } = {}) {
export function listCandidateUsers({ roleId = 8, top = 10, skip = 0, assignedJobPostId } = {}) {
return request('/candidate/fetch/users', {
params: { role_id: roleId, top, skip },
params: { role_id: roleId, top, skip, assigned_job_post_id: assignedJobPostId },
})
}
/** Total candidate-role users. Called once when the Candidates page opens. */
export function countCandidateUsers({ roleId = 8, search } = {}) {
export function countCandidateUsers({ roleId = 8, search, assignedJobPostId } = {}) {
return request('/candidate/fetch/users/count', {
params: { role_id: roleId, search },
params: { role_id: roleId, search, assigned_job_post_id: assignedJobPostId },
})
}
/**
* `users` row -> the row shape the Candidates table renders.
*
* A user account carries identity only. Everything the ATS produces
* (score, matched skills, critique, the job it was scored against) lives in the
* `candidates` table keyed by job_id + content hash, with no user_id to join on, so
* those fields are null here by construction rather than by omission.
*/
export function toCandidateUserView(row) {
return {
id: row.id,
@ -209,7 +176,7 @@ export function toCandidateUserView(row) {
email: row.email ?? null,
isActive: row.is_active ?? null,
roleName: row.role_name ?? null,
applied: row.created_at ? new Date(row.created_at) : null,
applied: toDate(row.created_at),
// No ATS data on a users row — see the note above.
jobId: null,
filename: null,
@ -274,46 +241,24 @@ export function toApplicationListView(row) {
aiScore: score,
recommendation: bandOf(score, row.recommendation || null),
applied: row.created_at ? new Date(row.created_at) : (row.applied ? new Date(row.applied) : null),
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
}
/**
* Candidate profiles the `inbox -> users -> roles` join, restricted server-side
* to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile).
*
* Permissioned with require_permission(CANDIDATES_VIEW), so a caller without the
* tag gets a 403.
*
* `search` is an ilike over users.name / users.email only it does NOT reach
* the résumé text or the suggested job titles.
*/
export function list({ search, limit, offset } = {}) {
return request('/candidate/fetch', { params: { search, limit, offset } })
export function list({ search, limit, offset, assignedJobPostId } = {}) {
return request('/candidate/fetch', {
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId },
})
}
/**
* One candidate by users.id.
*
* Passing user_id switches the endpoint into DETAIL mode
* (backend/job/candidate/views.py:get_candidate), which is a different and much
* larger payload than the list rows: résumé text, the AI match verdict, phone,
* education, source, documents, favorite/rating, and the four child collections
* interviews, activity, feedback, notes flattened across every inbox row the
* candidate owns.
*
* NOTE the asymmetric response: get_candidate_profile returns a BARE OBJECT
* rather than a one-element list when user_id matches exactly one row
* (backend/inbox/models.py:68-70). Callers must normalise see toRows().
*/
export function getByUserId(userId) {
return request('/candidate/fetch', { params: { user_id: userId } })
}
/**
* Candidates allocated to jobs this hiring manager owns requisitions they
* created (or are assigned on) linked job posts applications.
* Needs candidates.view. Server-scoped; recruiters should not use this.
*/
export function listForManager({ limit = 50, offset = 0 } = {}) {
return request('/candidate/manager/fetch', { params: { limit, offset } })
}
@ -324,6 +269,21 @@ export function toRows(res) {
return res?.data ? [res.data] : []
}
export function jobIdsOf(row) {
if (!row || typeof row !== 'object') return []
const ids = []
const add = (value) => {
if (value == null || value === '') return
const id = String(value)
if (!ids.includes(id)) ids.push(id)
}
add(row.assigned_job_post_id)
add(row.assigned_job_post?.id)
add(row.job_post_id)
return ids
}
/**
* favorite/rating live on the `inbox` row, not on the user, so the server applies
* the change to EVERY application belonging to the candidate and hands back the
@ -465,10 +425,18 @@ export function createActivity({ inboxId, type, status, description }) {
* detail query refetches on every write in the modal. Fetched lazily when the
* History tab opens, paginated server-side.
*/
export function listHistory(userId, { limit = 200, offset = 0 } = {}) {
export function listHistory(userId, { limit = 10, offset = 0 } = {}) {
return request('/candidate/history/fetch', { params: { user_id: userId, limit, offset } })
}
/**
* Prior applications for one email across users, candidates, manual upload,
* form_data (and inbox). Used by Add Candidate to warn on reapply.
*/
export function fetchApplicationHistory(email) {
return request('/candidate/applications/fetch', { params: { email } })
}
/**
* Authenticated attachment download. Never send a filesystem path the server
* resolves by owning record + index. `inboxId` is the `inbox` table PK (int),

View File

@ -1,4 +1,5 @@
import { request } from '../lib/apiClient'
import { toDate } from '../lib/format'
/* ============================================================
costs.js hiring costs, backend/job/app.py `/job/costs/*` (jobs.view / jobs.edit).
@ -38,8 +39,8 @@ export function toCostView(row) {
amount: Number(row.amount ?? 0),
currency: row.currency || 'USD',
description: row.description || null,
incurredAt: row.incurred_at ? new Date(row.incurred_at) : null,
created: row.created_at ? new Date(row.created_at) : null,
incurredAt: toDate(row.incurred_at),
created: toDate(row.created_at),
}
}

View File

@ -1,4 +1,5 @@
import { request } from '../lib/apiClient'
import { toDate } from '../lib/format'
/* ============================================================
interviews.js backend/job/app.py `/interview/*`.
@ -14,9 +15,9 @@ import { request } from '../lib/apiClient'
is required" so `list()` always sends `top`, and the screens never call it
bare.
Permissions are candidates.*, NOT interviews.* the eight interviews.* tags
exist in the catalogue but no route reads them. A user holding only
interviews.view gets a 403 here.
Permissions are interviews.* OR candidates.* (either tag is enough). A custom
role with only the interviews_tab bundle can list/schedule here without
candidates.view. Recruiter / hiring_manager still pass via candidates.*.
============================================================ */
/** Status vocabulary. `interview_status` is a free-text column, so this file is
@ -104,7 +105,7 @@ export function update(interviewId, { instant, type, status } = {}) {
*/
export function toInterviewView(row) {
const whenRaw = row.interview_date || row.interview_time
const when = whenRaw ? new Date(whenRaw) : null
const when = toDate(whenRaw)
return {
id: row.id,
inboxId: row.inbox_id,

View File

@ -6,6 +6,7 @@
============================================================ */
import { request } from '../lib/apiClient'
import { toDate } from '../lib/format'
import { REQUISITION_STATUSES } from './jobs'
const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label]))
@ -30,7 +31,7 @@ export function list({ jobPostId, search, ids, top, skip, activeOnly } = {}) {
/** Whole days from created_at to the browser clock. Null if timestamp missing. */
export function daysOpen(createdAt, now = Date.now()) {
if (!createdAt) return null
const start = new Date(createdAt).getTime()
const start = toDate(createdAt)?.getTime()
if (!Number.isFinite(start)) return null
return Math.max(0, Math.floor((now - start) / 86_400_000))
}

View File

@ -1,4 +1,5 @@
import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
import { toDate } from '../lib/format'
/**
* Job requisitions backend/job/app.py `GET /jobs/fetch`.
@ -82,8 +83,8 @@ export function toJobView(row) {
createdByName: row.created_by_name,
applicantCount: row.applicant_count ?? 0,
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.
created: row.created_at ? new Date(row.created_at) : null,
closedAt: row.closed_at ? new Date(row.closed_at) : null,
created: toDate(row.created_at),
closedAt: toDate(row.closed_at),
requisitionStatus: row.requisition_status,
experienceMin: row.experience_min,
experienceMax: row.experience_max,

View File

@ -1,4 +1,5 @@
import { request } from '../lib/apiClient'
import { toDate } from '../lib/format'
/** In-app notifications — backend/notifications/app.py. Scoped to the caller; no RBAC tag. */
@ -35,8 +36,8 @@ export function remove(recordId) {
function relTime(iso) {
if (!iso) return ''
const then = new Date(iso)
if (Number.isNaN(then.getTime())) return ''
const then = toDate(iso)
if (!then) return ''
const mins = Math.max(0, Math.round((Date.now() - then.getTime()) / 60000))
if (mins < 60) return `${mins}m ago`
if (mins < 1440) return `${Math.floor(mins / 60)}h ago`

View File

@ -1,4 +1,5 @@
import { request } from '../lib/apiClient'
import { toDate } from '../lib/format'
/* ============================================================
offers.js backend/offer/app.py.
@ -132,11 +133,11 @@ export function toOfferView(row, { people, jobTitles } = {}) {
noticePeriod: row.notice_period || null,
workLocation: row.work_location || null,
workTimings: row.work_timings || null,
startDate: row.start_date ? new Date(row.start_date) : null,
expiry: row.expiry_date ? new Date(row.expiry_date) : null,
sent: row.sent_at ? new Date(row.sent_at) : null,
respondedAt: row.responded_at ? new Date(row.responded_at) : null,
closedAt: row.closed_at ? new Date(row.closed_at) : null,
created: row.created_at ? new Date(row.created_at) : null,
startDate: toDate(row.start_date),
expiry: toDate(row.expiry_date),
sent: toDate(row.sent_at),
respondedAt: toDate(row.responded_at),
closedAt: toDate(row.closed_at),
created: toDate(row.created_at),
}
}

View File

@ -10,6 +10,52 @@ export function update(settings) {
return request('/org-settings/update', { method: 'PUT', body: { settings } })
}
export function listUniversities() {
return request('/org-settings/exclude-university/fetch')
}
export function createUniversity(body) {
return request('/org-settings/exclude-university/create', { method: 'POST', body })
}
export function updateUniversity(recordId, body) {
return request('/org-settings/exclude-university/update', {
method: 'PATCH',
params: { record_id: recordId },
body,
})
}
export function removeUniversity(recordId) {
return request('/org-settings/exclude-university/delete', {
method: 'DELETE',
params: { record_id: recordId },
})
}
export function listCompanies() {
return request('/org-settings/exclude-company/fetch')
}
export function createCompany(body) {
return request('/org-settings/exclude-company/create', { method: 'POST', body })
}
export function updateCompany(recordId, body) {
return request('/org-settings/exclude-company/update', {
method: 'PATCH',
params: { record_id: recordId },
body,
})
}
export function removeCompany(recordId) {
return request('/org-settings/exclude-company/delete', {
method: 'DELETE',
params: { record_id: recordId },
})
}
/** Flatten `{data:[{key,value,category}]}` into a key → value map. */
export function toMap(res) {
const rows = Array.isArray(res?.data) ? res.data : []

View File

@ -11,6 +11,7 @@
============================================================ */
import { request } from '../lib/apiClient'
import { toDate } from '../lib/format'
/**
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
@ -214,7 +215,9 @@ export function toBoardCard(row, kind = 'inbox') {
experience: asText(row.experience),
aiScore: asScore(row.ats_result?.overall_score),
recommendation: asText(row.ats_result?.band),
applied: row.created_at ? new Date(row.created_at) : null,
applied: toDate(row.created_at),
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
}

View File

@ -34,3 +34,12 @@ export function listPermissionTags() {
export function updatePermissionTags(body) {
return request('/roles/permission-tags/update', { method: 'PUT', body })
}
/** Access Control grid save — PUT onto a per-role overlay bundle. */
export function updateRoleMatrix(recordId, permissionTags) {
return request('/roles/matrix/update', {
method: 'PUT',
params: { record_id: recordId },
body: { permission_tags: permissionTags },
})
}

View File

@ -9,6 +9,7 @@
============================================================ */
import { request } from '../lib/apiClient'
import { toDate } from '../lib/format'
const TERMINAL = new Set(['succeeded', 'failed', 'timed_out', 'aborted'])
@ -91,9 +92,9 @@ export function toRunView(row) {
profilesFound: row.profiles_found ?? 0,
costUsd: row.cost_usd ?? null,
error: row.apify_error ?? null,
startedAt: row.started_at ? new Date(row.started_at) : null,
finishedAt: row.finished_at ? new Date(row.finished_at) : null,
createdAt: row.created_at ? new Date(row.created_at) : null,
startedAt: toDate(row.started_at),
finishedAt: toDate(row.finished_at),
createdAt: toDate(row.created_at),
isTerminal: TERMINAL.has(row.status),
}
}
@ -104,7 +105,7 @@ export function toAccountView(row) {
balanceUsd: r.balance_usd ?? null,
spentUsd: r.spent_this_cycle_usd ?? null,
monthlyLimitUsd: r.monthly_limit_usd ?? null,
cycleEndsAt: r.cycle_ends_at ? new Date(r.cycle_ends_at) : null,
cycleEndsAt: toDate(r.cycle_ends_at),
// null until at least one search has recorded its cost (runs from before
// cost tracking carry no spend data and are excluded from the average).
costPerProfileUsd: r.cost_per_profile_usd ?? null,
@ -129,11 +130,11 @@ export function toProfileView(row) {
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,
shortlistedAt: toDate(row.shortlisted_at),
shortlistedByName: row.shortlisted_by_name ?? null,
contactedAt: row.contacted_at ? new Date(row.contacted_at) : null,
contactedAt: toDate(row.contacted_at),
contactedByName: row.contacted_by_name ?? null,
lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at) : null,
lastSeenAt: toDate(row.last_seen_at),
// 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 }
alreadyApplied: row.already_applied ?? null,
@ -143,7 +144,7 @@ export function toProfileView(row) {
export function toProfileDetailView(row) {
return {
...toProfileView(row),
firstSeenAt: row.first_seen_at ? new Date(row.first_seen_at) : null,
firstSeenAt: toDate(row.first_seen_at),
experience: (row.experience ?? []).map((e) => ({
title: e.title,
company: e.company,

View File

@ -1,4 +1,5 @@
import { request } from '../lib/apiClient'
import { toDate } from '../lib/format'
/**
* Tasks backend/tasks/app.py.
@ -44,10 +45,10 @@ export function toTaskView(row) {
title: row.title,
done: row.status === 'done',
priority: row.priority ? row.priority[0].toUpperCase() + row.priority.slice(1) : 'Medium',
due: row.due_date ? new Date(row.due_date) : null,
due: toDate(row.due_date),
assignee: row.assignee_name || '—',
assigneeRole: row.assignee_role || null,
assigneeId: row.assignee_id,
created: row.created_at ? new Date(row.created_at) : null,
created: toDate(row.created_at),
}
}

View File

@ -3,8 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Dropdown, { DropdownGroup } from '../ui/Dropdown'
import GlobalSearch from './GlobalSearch'
import { Avatar, Icon } from '../ui/primitives'
import { seedQuery } from '../data/seedQueries'
import { Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useTheme } from '../theme/ThemeProvider'
import { useAuth } from '../auth/AuthContext'
@ -40,8 +39,6 @@ export default function Topbar({ onOpenNav, searchRef }) {
const notifications = notifQuery.data?.items ?? []
const unread = notifQuery.data?.unread ?? 0
const { data: messages = [] } = useQuery(seedQuery('messages'))
const markAll = useMutation({
mutationFn: () => notificationsApi.markAllRead(),
onError: (err) => toast(friendlyAuthError(err, 'Could not mark all as read.'), 'error'),
@ -85,33 +82,6 @@ export default function Topbar({ onOpenNav, searchRef }) {
</button>
<DropdownGroup>
<Dropdown
panelClassName="dropdown-menu-wide"
trigger={({ toggle }) => (
<button className="icon-btn" onClick={toggle} title="Messages" aria-label="Messages">
<Icon name="message" />
{messages.some((m) => m.unread) && <span className="dot dot-blue" />}
</button>
)}
>
<div className="dropdown-head">Messages</div>
<div className="dd-scroll">
{messages.map((m) => (
<div key={m.id ?? m.name} className={`notif-row${m.unread ? ' unread' : ''}`}>
<Avatar name={m.name} initials={m.initials} color={m.color} />
<div className="notif-body">
<div className="notif-title">{m.name}</div>
<div className="notif-text">{m.text}</div>
<div className="notif-time">{m.time} ago</div>
</div>
</div>
))}
</div>
<div className="dropdown-foot">
<Link to="/inbox">Open inbox</Link>
</div>
</Dropdown>
<Dropdown
panelClassName="dropdown-menu-wide"
trigger={({ toggle }) => (

View File

@ -48,3 +48,29 @@ export function isHiringManager(user) {
const name = (user?.role_name || '').trim().toLowerCase()
return name === HIRING_MANAGER_ROLE || name === 'manager'
}
const ADMIN_ROLES = new Set(['system_administrator', 'hr_administrator', 'admin'])
export function isAdmin(user) {
const name = (user?.role_name || '').trim().toLowerCase()
if (ADMIN_ROLES.has(name)) return true
return (user?.permissions || []).includes('requisitions.manage')
}
/** Unscoped Candidates list — matches backend sees_all_candidates. */
export function seesAllCandidates(user) {
if (isAdmin(user)) return true
return (user?.permissions || []).includes('candidates.manage')
}
/** Jobs/candidates limited to requisitions this user created (or is assigned).
Matches backend `scopes_to_own_requisitions`. Custom roles opt in from
Access Control by ticking Requisitions Configure (`requisitions.configure`),
not Create. `requisitions.manage` already means admin. Do not expand
`isHiringManager` for this; that helper still locks the sidebar. */
export function scopesToOwnRequisitions(user) {
if (isHiringManager(user)) return true
if (isAdmin(user) || seesAllCandidates(user)) return false
return (user?.permissions || []).includes('requisitions.configure')
}

View File

@ -0,0 +1,138 @@
import { Badge } from '../ui/primitives'
import { STAGE_FROM_STATUS } from '../api/pipeline'
import { fmtDate } from '../lib/format'
const SOURCE_LABEL = {
inbox: 'Email',
manual: 'Manual',
form: 'Form',
ats: 'ATS',
}
const STAGE_BADGE = {
Shortlist: 'b-indigo',
Screening: 'b-teal',
Assessment: 'b-purple',
Interview: 'b-amber',
Offer: 'b-green',
Approved: 'b-green',
Hired: 'b-green',
'On Hold': 'b-amber',
Rejected: 'b-gray',
}
export function applicationStatusLabel(status) {
if (status == null || status === '') return 'Shortlist'
const key = String(status).toUpperCase()
if (STAGE_FROM_STATUS[key]) return STAGE_FROM_STATUS[key]
const extras = {
UNREAD: 'Unread',
IMPORTED: 'Imported',
PROCESSED: 'Processed',
COMPLETED: 'ATS scored',
FAILED: 'ATS failed',
BANKED: 'CV bank',
}
if (extras[key]) return extras[key]
return key.charAt(0) + key.slice(1).toLowerCase()
}
export function previousApplicationsOf(row) {
if (!row) return []
if (Array.isArray(row.previousApplications)) return row.previousApplications
if (Array.isArray(row.previous_applications)) return row.previous_applications
return []
}
export function isReapplicant(row) {
if (!row) return false
if (row.isReapplicant === true || row.is_reapplicant === true) return true
return previousApplicationsOf(row).length > 0
}
export function previousApplicationsTip(row) {
const items = previousApplicationsOf(row)
if (!items.length) return 'Applied before'
return items.map((item) => {
const job = item.job_title || item.jobTitle || 'Unassigned job'
return `${job}${applicationStatusLabel(item.status)}`
}).join('\n')
}
/** Compact chip for tables, kanban cards, and inbox rows. */
export function ReappliedBadge({ row, className = '' }) {
if (!isReapplicant(row)) return null
const count = previousApplicationsOf(row).length
return (
<span
className={`badge b-amber badge-plain ${className}`.trim()}
style={{ marginLeft: 6, fontSize: 10, padding: '1px 6px' }}
title={previousApplicationsTip(row)}
>
Reapplied{count > 1 ? ` · ${count}` : ''}
</span>
)
}
/** Full prior-job list for profile / inbox / add-candidate. */
export function PreviousApplications({ row, title = 'Previous applications' }) {
const items = previousApplicationsOf(row)
if (!items.length) return null
return (
<div
className="card"
style={{
boxShadow: 'none',
background: 'var(--warning-soft)',
border: '1px solid var(--warning)',
marginBottom: 18,
}}
>
<div className="card-body">
<div
style={{
fontSize: 12,
color: 'var(--warning)',
fontWeight: 700,
textTransform: 'uppercase',
marginBottom: 10,
}}
>
{title}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, idx) => {
const stage = applicationStatusLabel(item.status)
const key = [
item.source,
item.inbox_id,
item.manual_upload_candidate_id,
item.form_data_id,
item.candidate_id,
item.job_post_id,
idx,
].filter(Boolean).join(':')
return (
<div
key={key}
className="flex items-center gap-8"
style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<div style={{ minWidth: 0 }}>
<div className="fw-600" style={{ fontSize: 13 }}>
{item.job_title || item.jobTitle || 'No job assigned'}
</div>
<div className="cell-sub">
{SOURCE_LABEL[item.source] || item.source || 'Application'}
{item.applied_at ? ` · ${fmtDate(item.applied_at)}` : ''}
</div>
</div>
<Badge className={STAGE_BADGE[stage] || 'b-gray'}>{stage}</Badge>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@ -9,9 +9,12 @@
endpoints exist read the API instead; the rest resolve from here.
============================================================ */
import { fmtDate, fmtShort } from '../lib/format'
// The prototype pinned "today" to 2026-07-09 in ~8 places across five files so
// the generated relative dates stayed stable. Exported from one place now, so
// switching the app to real time is a one-line change.
export const TODAY = new Date('2026-07-09T09:00:00');
// ---------- seeded pseudo-random for stable data ----------
@ -63,13 +66,6 @@ export const TODAY = new Date('2026-07-09T09:00:00');
function email(name) { return name.toLowerCase().replace(/[^a-z ]/g, '').replace(/ /g, '.') + '@' + pick(['gmail.com', 'outlook.com', 'proton.me', 'icloud.com']); }
function phone() { return '+1 (' + int(200, 989) + ') ' + int(200, 999) + '-' + int(1000, 9999); }
function daysAgo(n) { const d = new Date(TODAY); d.setDate(d.getDate() - n); return d; }
function fmtDate(d) {
if (d == null || d === '') return ''
const date = d instanceof Date ? d : new Date(d)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
function fmtShort(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }
const avatarColors = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)'];
function avatarColor(name) {
@ -233,7 +229,7 @@ export const TODAY = new Date('2026-07-09T09:00:00');
// ---------- Notifications ----------
const notifications = [
{ icon: 'user-plus', color: 'i-green', title: 'New application', text: candidates[0].name + ' applied for ' + candidates[0].jobTitle, time: '8m ago', unread: true },
{ icon: 'calendar', color: 'i-blue', title: 'Interview reminder', text: 'Technical interview at 2:00 PM today', time: '25m ago', unread: true },
{ icon: 'calendar', color: 'i-blue', title: 'Interview reminder', text: 'Technical interview at 2:00pm today', time: '25m ago', unread: true },
{ icon: 'check', color: 'i-teal', title: 'Offer accepted', text: offers[0].candidate + ' accepted the offer 🎉', time: '1h ago', unread: true },
{ icon: 'message', color: 'i-purple', title: 'New message', text: 'Hiring manager left feedback on a candidate', time: '2h ago', unread: true },
{ icon: 'star', color: 'i-amber', title: 'Assessment completed', text: assessments[0].candidate + ' scored 87% on Coding Challenge', time: '4h ago', unread: false },

View File

@ -9,3 +9,117 @@ export function formatRole(name) {
.map((w) => (w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w))
.join(' ')
}
/* Platform date/time display. One spelling everywhere:
date 3rd Sept 2026
time 7:30pm (12-hour, lowercase, no space)
both 3rd Sept 2026 - 7:30pm
API timestamps are year-month-day, then optional time, e.g.
2026-08-28 03:56:19.685066-07
2026-08-28T03:56:19.685066-07:00
2026-08-28
Display uses those numbers as written: 2026 = year, 08 = month (Aug),
28 = day, 03:56 = 3:56am. The timezone suffix is not applied. */
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec']
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const WEEKDAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const API_TS = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{1,2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?/
/** Parse an API timestamp into a Date whose local fields match the payload. */
export function toDate(value) {
if (value == null || value === '') return null
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value
}
if (typeof value === 'number' && Number.isFinite(value)) {
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
const s = String(value).trim()
const m = s.match(API_TS)
if (!m) return null
const year = Number(m[1])
const month = Number(m[2])
const day = Number(m[3])
const hour = m[4] != null ? Number(m[4]) : 0
const minute = m[5] != null ? Number(m[5]) : 0
const second = m[6] != null ? Number(m[6]) : 0
if (month < 1 || month > 12 || day < 1 || day > 31) return null
const d = new Date(year, month - 1, day, hour, minute, second)
if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return null
return d
}
function ordinal(n) {
const v = n % 100
if (v >= 11 && v <= 13) return `${n}th`
switch (n % 10) {
case 1: return `${n}st`
case 2: return `${n}nd`
case 3: return `${n}rd`
default: return `${n}th`
}
}
/** 3rd Sept 2026 */
export function fmtDate(value) {
const d = toDate(value)
if (!d) return ''
return `${ordinal(d.getDate())} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`
}
/** Same as fmtDate — the platform uses one date spelling. */
export function fmtShort(value) {
return fmtDate(value)
}
/** 7:30pm */
export function fmtTime(value) {
const d = toDate(value)
if (!d) return ''
let hours = d.getHours()
const minutes = d.getMinutes()
const suffix = hours >= 12 ? 'pm' : 'am'
hours = hours % 12
if (hours === 0) hours = 12
return `${hours}:${String(minutes).padStart(2, '0')}${suffix}`
}
/** 3rd Sept 2026 - 7:30pm */
export function fmtDateTime(value, sep = ' - ') {
const d = toDate(value)
if (!d) return ''
return `${fmtDate(d)}${sep}${fmtTime(d)}`
}
/** Sept 2026 — calendar month headers, not a day. */
export function fmtMonthYear(value) {
const d = toDate(value)
if (!d) return ''
return `${MONTHS[d.getMonth()]} ${d.getFullYear()}`
}
/** Thursday, 3rd Sept 2026 — dashboard "today" line. */
export function fmtWeekdayDate(value) {
const d = toDate(value)
if (!d) return ''
return `${WEEKDAYS[d.getDay()]}, ${fmtDate(d)}`
}
export function fmtWeekdayShort(value) {
const d = toDate(value)
if (!d) return ''
return WEEKDAYS_SHORT[d.getDay()]
}
/** yyyy-mm-dd for <input type="date">, from the payload's calendar day. */
export function toDateInput(value) {
const d = toDate(value)
if (!d) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}

View File

@ -59,6 +59,8 @@ export const qk = {
orgSettings: {
all: () => ['orgSettings'],
list: (p = {}) => ['orgSettings', 'list', p],
universities: () => ['orgSettings', 'exclude-universities'],
companies: () => ['orgSettings', 'exclude-companies'],
},
savedSearches: {
all: () => ['savedSearches'],
@ -98,6 +100,7 @@ export const qk = {
count: (p = {}) => ['candidates', 'count', p],
detail: (id) => ['candidates', 'detail', id],
history: (id, p = {}) => ['candidates', 'history', id, p],
applications: (email) => ['candidates', 'applications', email],
matching: (p = {}) => ['candidates', 'matching', p],
matchingDetail: (id) => ['candidates', 'matching', 'detail', id],
},

View File

@ -34,6 +34,7 @@ import ChartCard from '../ui/ChartCard'
import DataTable from '../ui/DataTable'
import PageHeader from '../ui/PageHeader'
import { EmptyState, Icon } from '../ui/primitives'
import { fmtDate } from '../lib/format'
import { qk } from '../lib/queryKeys'
import { RANGES, rangeWindow } from '../lib/timeRanges'
import { friendlyAuthError } from '../lib/errors'
@ -117,8 +118,8 @@ function AskAnalyticsCard() {
<p className="text-muted" style={{ marginTop: 8, fontSize: 12 }}>
<Icon name="info" /> Answered from the {INTENT_LABELS[result.intent] ?? result.intent} query
{result.params?.department ? ` · ${result.params.department}` : ''}
{result.params?.from_date ? ` · from ${new Date(result.params.from_date).toLocaleDateString()}` : ''}
{result.params?.to_date ? ` · to ${new Date(result.params.to_date).toLocaleDateString()}` : ''}
{result.params?.from_date ? ` · from ${fmtDate(result.params.from_date)}` : ''}
{result.params?.to_date ? ` · to ${fmtDate(result.params.to_date)}` : ''}
</p>
)}
{tableColumns && (

View File

@ -22,6 +22,7 @@ import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as interviewsApi from '../api/interviews'
import { byInboxId, useApplications } from '../lib/useApplications'
import { fmtDate, fmtMonthYear, fmtTime } from '../lib/format'
import { avatarColor, initials as initialsOf } from '../data/seed'
/* Round -> event colour. Unknown rounds fall through to blue rather than
@ -105,7 +106,7 @@ export default function Calendar() {
}, [events])
const cells = useMemo(() => buildCells(year, month), [year, month])
const monthName = new Date(year, month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
const monthName = fmtMonthYear(new Date(year, month, 1))
const todayKey = today.toDateString()
const todayIvs = byDay.get(todayKey) ?? []
@ -157,7 +158,7 @@ export default function Calendar() {
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load the calendar">
{friendlyAuthError(monthQuery.error, 'The server did not return interviews.')}
{' '}This screen needs the <code>candidates.view</code> permission.
{' '}This screen needs the <code>interviews.view</code> permission.
</EmptyState>
</div>
</div>
@ -180,7 +181,7 @@ export default function Calendar() {
title={`${iv.candidate} · ${iv.type}${iv.jobTitle ? ` · ${iv.jobTitle}` : ''}`}
onClick={() => openCandidate(iv.userId)}
>
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]}
{fmtTime(iv.when)} {iv.candidate.split(' ')[0]}
</div>
))}
{dayEvents.length > 3 && (
@ -198,7 +199,7 @@ export default function Calendar() {
<div>
<h3>Today</h3>
<span className="ch-sub">
{today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
{fmtDate(today)}
</span>
</div>
</div>
@ -227,7 +228,7 @@ export default function Calendar() {
</div>
<div className="lr-right">
<div className="fw-600 text-sm">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
{fmtTime(iv.when)}
</div>
{iv.webLink && (
<a

View File

@ -23,6 +23,7 @@ import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import { fmtDate, toDateInput } from '../lib/format'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as formsApi from '../api/forms'
@ -53,12 +54,6 @@ function titleCase(status) {
return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Shortlist'
}
function toDateInput(value) {
if (!value) return ''
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10)
}
/** Same shape as the profile's useProfileWrite, plus the forms/offers caches. */
function useFormsWrite({ userId, mutationFn, success, onDone }) {
const qc = useQueryClient()
@ -276,7 +271,7 @@ function FormRowList({ rows, defs, onEdit, canEdit }) {
<div className="lr-main">
<div className="lr-title">{r.interviewer_name || r.created_by_name || 'Unknown'}</div>
<div className="lr-sub">
{toDateInput(r.form_date) || toDateInput(r.created_at)}
{fmtDate(r.form_date) || fmtDate(r.created_at) || '—'}
{r.updated_at && r.updated_at !== r.created_at ? ' · revised' : ''}
</div>
</div>

View File

@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import OpenResumeButton from '../ui/OpenResumeButton'
import { Tabs } from '../ui/Tabs'
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
@ -17,7 +18,9 @@ import * as formsApi from '../api/forms'
import * as pipelineApi from '../api/pipeline'
import * as s3Api from '../api/s3'
import CandidateFormsTab from './CandidateForms'
import { companies, fmtDate, moneyK, pick } from '../data/seed'
import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory'
import { fmtDate, fmtTime, toDate } from '../lib/format'
import { companies, moneyK, pick } from '../data/seed'
/* Workflow order: learn (Overview, Resume, Documents) interview (Interview,
Forms) track (Notes, Activity) audit (Timeline, History). */
@ -29,22 +32,19 @@ const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture
const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']
/** Seed timestamps are Date objects; the API sends ISO strings. */
/** Seed timestamps are Date objects; the API sends YYYY-MM-DD[ T]HH:MM strings. */
function fmtWhen(value, fallback = '—') {
if (!value) return fallback
const d = value instanceof Date ? value : new Date(value)
return Number.isNaN(d.getTime()) ? String(value) : fmtDate(d)
return fmtDate(value) || fallback
}
function fmtClock(value) {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
return fmtTime(value) || null
}
function stamp(value) {
const d = value instanceof Date ? value : new Date(value ?? NaN)
return Number.isNaN(d.getTime()) ? 0 : d.getTime()
const d = toDate(value)
return d ? d.getTime() : 0
}
/** <input type="date"> + <input type="time"> -> one ISO instant, or null. */
@ -275,7 +275,10 @@ export default function CandidateProfile({
<div className="profile-hero">
<Avatar name={live?.name || c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{live?.name || c.name}</div>
<div className="ph-name">
{live?.name || c.name}
<ReappliedBadge row={live || c} />
</div>
<div className="ph-role">{company ? `${title} at ${company}` : title}</div>
<div className="ph-tags">
{stageLabel && <Badge>{stageLabel}</Badge>}{' '}
@ -323,6 +326,7 @@ export default function CandidateProfile({
<div className="tab-pane active">
{tab === 'Overview' && (guard || (live ? (
<>
<PreviousApplications row={live} />
<div className="info-grid" style={{ marginBottom: 20 }}>
<Info label="Email" val={live.email} />
<Info label="Phone" val={live.phone} />
@ -748,8 +752,8 @@ const HISTORY_TITLE = {
}
function historyDayLabel(value) {
const d = value instanceof Date ? value : new Date(value ?? NaN)
if (Number.isNaN(d.getTime())) return 'Unknown'
const d = toDate(value)
if (!d) return 'Unknown'
const today = new Date()
const yday = new Date()
yday.setDate(today.getDate() - 1)
@ -760,12 +764,13 @@ function historyDayLabel(value) {
}
function HistoryTab({ userId }) {
const [limit, setLimit] = useState(200)
const [skip, setSkip] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const q = useQuery({
queryKey: qk.candidates.history(userId, { limit }),
queryKey: qk.candidates.history(userId, { limit: pageSize, offset: skip }),
queryFn: async () => {
const res = await candidatesApi.listHistory(userId, { limit })
return { rows: res?.data ?? [], total: res?.total ?? 0 }
const res = await candidatesApi.listHistory(userId, { limit: pageSize, offset: skip })
return { rows: Array.isArray(res?.data) ? res.data : [], total: typeof res?.total === 'number' ? res.total : 0 }
},
enabled: Boolean(userId),
})
@ -783,7 +788,12 @@ function HistoryTab({ userId }) {
const rows = q.data?.rows ?? []
const total = q.data?.total ?? 0
if (!rows.length) {
const pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages)
const from = total ? skip + 1 : 0
const to = total ? skip + rows.length : 0
if (!rows.length && total === 0) {
return <EmptyState icon="clock" title="No history yet">Actions on this candidate will appear here.</EmptyState>
}
@ -805,10 +815,18 @@ function HistoryTab({ userId }) {
</div>
</div>
))}
{total > rows.length && (
<button className="btn btn-secondary btn-sm" style={{ marginTop: 8 }} onClick={() => setLimit((n) => n + 200)}>
Load more
</button>
{total > 0 && (
<Pagination
from={from}
to={to}
total={total}
page={currentPage}
pages={pages}
setPage={(p) => setSkip((p - 1) * pageSize)}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={(n) => { setPageSize(n); setSkip(0) }}
/>
)}
</>
)

View File

@ -3,9 +3,13 @@
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
per application, so score / stage / job / recruiter have a source.
Hiring managers use GET /candidate/manager/fetch (jobs on their
requisitions). Adding a candidate still goes through CV Import or the
Add Candidate modal both run the CV through persisted ATS scoring.
Without candidates.manage the server scopes that list to jobs the user
owns as recruiter (or created). Tick Requisitions Configure in Access
Control to see candidates on jobs opened from that user's requisitions;
recruiter assignment on the job does not hide them. Hiring managers use GET
/candidate/manager/fetch (same job chain). Adding a candidate
still goes through CV Import or the Add Candidate modal both run the CV
through persisted ATS scoring.
============================================================ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -18,7 +22,7 @@ import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import { isHiringManager, seesAllCandidates, scopesToOwnRequisitions } from '../auth/permissions'
import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys'
@ -27,6 +31,7 @@ import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import * as pipelineApi from '../api/pipeline'
import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory'
import { useFormState } from '../components/AuthLayout'
import { persist, useSeedMutation } from '../data/seedQueries'
import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed'
@ -44,11 +49,18 @@ const BAND_BADGE = {
/** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
async function fetchCandidates({ limit = DEFAULT_PAGE_SIZE, offset = 0, search } = {}) {
/* Rows are APPLICATIONS (GET /candidate/fetch), not candidate user accounts.
The user list (/candidate/fetch/users) is the wider population, but score,
stage, job and recruiter all hang off the application on a users row those
columns have no source at all. One row per application is what a recruiter
triages on, so the table follows the application. */
async function fetchCandidates({ limit = DEFAULT_PAGE_SIZE, offset = 0, search, assignedJobPostId } = {}) {
const res = await candidatesApi.list({
limit,
offset,
search: search || undefined,
assignedJobPostId: assignedJobPostId || undefined,
})
const rows = Array.isArray(res?.data) ? res.data : []
return {
@ -57,10 +69,16 @@ async function fetchCandidates({ limit = DEFAULT_PAGE_SIZE, offset = 0, search }
}
}
async function fetchJobs() {
async function fetchJobs({ createdBy } = {}) {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title }))
return rows
.filter((row) => row && row.id != null)
.filter((row) => !createdBy || String(row.created_by || '') === String(createdBy))
.map((row) => ({
id: String(row.id),
title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled',
}))
}
function recommendationOf(c) {
@ -139,6 +157,7 @@ function HiringManagerCandidates() {
const navigate = useNavigate()
const location = useLocation()
const [q, setQ] = useState('')
const [jobId, setJobId] = useState('')
useEffect(() => {
const id = location.state?.openCandidate
@ -153,14 +172,27 @@ function HiringManagerCandidates() {
},
})
const rowsAll = listQuery.data ?? []
const jobs = useMemo(() => {
const seen = new Set()
const out = []
for (const r of rowsAll) {
const id = r.job_post_id
if (id == null || seen.has(String(id))) continue
seen.add(String(id))
out.push({ id: String(id), title: r.job_title || 'Untitled' })
}
out.sort((a, b) => a.title.localeCompare(b.title))
return out
}, [rowsAll])
const rows = useMemo(() => {
if (!q.trim()) return rowsAll
const needle = q.trim().toLowerCase()
return rowsAll.filter((r) => {
if (jobId && String(r.job_post_id) !== jobId) return false
if (!needle) return true
const hay = [r.name, r.email, r.job_title].filter(Boolean).join(' ').toLowerCase()
return hay.includes(needle)
})
}, [rowsAll, q])
}, [rowsAll, q, jobId])
const columns = [
{
@ -170,7 +202,10 @@ function HiringManagerCandidates() {
sortValue: (r) => r.name || '',
render: (r) => (
<>
<div className="cell-primary">{r.name || '—'}</div>
<div className="cell-primary">
{r.name || '—'}
<ReappliedBadge row={r} />
</div>
<div className="cell-sub">{r.email || '—'}</div>
</>
),
@ -206,7 +241,7 @@ function HiringManagerCandidates() {
sortValue: (r) => r.created_at || '',
render: (r) => (
<span className="text-muted">
{r.created_at ? fmtDate(new Date(r.created_at)) : '—'}
{r.created_at ? fmtDate(r.created_at) : '—'}
</span>
),
},
@ -229,6 +264,12 @@ function HiringManagerCandidates() {
onChange={(e) => setQ(e.target.value)}
/>
</div>
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option>
{jobs.map((j) => (
<option key={j.id} value={j.id}>{j.title}</option>
))}
</select>
</div>
{listQuery.isPending && <SkeletonRows rows={4} />}
{listQuery.isError && (
@ -240,7 +281,11 @@ function HiringManagerCandidates() {
<DataTable
columns={columns}
rows={rows}
empty="No candidates are allocated to jobs opened from your requisitions yet."
empty={
jobId || q
? 'No candidates match these filters.'
: 'No candidates are allocated to jobs opened from your requisitions yet.'
}
onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)}
/>
)}
@ -255,9 +300,13 @@ function RecruiterCandidates() {
const qc = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const { user } = useAuth()
const updateCandidates = useSeedMutation('candidates')
const unscoped = seesAllCandidates(user)
const requisitionScoped = scopesToOwnRequisitions(user)
const [q, setQ] = useState('')
const [jobId, setJobId] = useState('')
const [search, setSearch] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false)
@ -275,10 +324,27 @@ function RecruiterCandidates() {
useEffect(() => { setSkip(0) }, [search])
const candidatesQuery = useQuery({
queryKey: qk.candidates.list({ limit: pageSize, offset: skip, search }),
queryFn: () => fetchCandidates({ limit: pageSize, offset: skip, search }),
queryKey: qk.candidates.list({
limit: pageSize,
offset: skip,
search,
assignedJobPostId: jobId || undefined,
}),
queryFn: () => fetchCandidates({
limit: pageSize,
offset: skip,
search,
assignedJobPostId: jobId || undefined,
}),
})
const jobsQuery = useQuery({
queryKey: qk.jobPosts.list({
createdBy: unscoped ? 'all' : requisitionScoped ? 'requisition' : (user?.id ?? null),
}),
queryFn: () => fetchJobs({
createdBy: unscoped || requisitionScoped ? undefined : user?.id,
}),
})
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [candidatesQuery.data])
const jobsById = useMemo(
() => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])),
@ -447,7 +513,7 @@ function RecruiterCandidates() {
await exportStyledXlsx({
filename: `candidates-${new Date().toISOString().slice(0, 10)}`,
title: 'Candidates',
subtitle: `${rows.length} application${rows.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
subtitle: `${rows.length} application${rows.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`,
columns: [
{ header: 'Name', key: 'name', width: 26 },
{ header: 'Email', key: 'email', width: 30 },
@ -466,7 +532,7 @@ function RecruiterCandidates() {
band: recommendationOf(c) || '',
stage: c.stage || '',
recruiter: c.recruiter || '',
applied: c.applied ? c.applied.toLocaleDateString() : '',
applied: c.applied ? fmtDate(c.applied) : '',
})),
})
toast(`Exported ${rows.length} candidate${rows.length === 1 ? '' : 's'}`, 'success')
@ -504,6 +570,19 @@ function RecruiterCandidates() {
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name or email…" />
</div>
<select
className="select"
value={jobId}
onChange={(e) => {
setJobId(e.target.value)
setSkip(0)
}}
>
<option value="">All Jobs</option>
{(jobsQuery.data ?? []).map((j) => (
<option key={j.id} value={j.id}>{j.title}</option>
))}
</select>
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
<Icon name="filter" /> Filters
</button>
@ -525,6 +604,8 @@ function RecruiterCandidates() {
className="filter-panel"
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
>
{/* Job stays a toolbar dropdown, sent as assigned_job_post_id on
GET /candidate/fetch. */}
<Facet label="Stage" value={filters.stage} onChange={(v) => setFilter('stage', v)} any="Any stage" options={STAGE_FILTERS} />
<Facet label="ATS band" value={filters.band} onChange={(v) => setFilter('band', v)} any="Any band" options={BAND_FILTERS} />
<Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any account" options={['Active', 'Unconfirmed']} />
@ -554,8 +635,14 @@ function RecruiterCandidates() {
{t.pageRows.length === 0 ? (
<tr>
<td colSpan={columns.length}>
<EmptyState title="No applications here">
Import a CV or add a candidate to see score, stage, and recruiter on this table.
<EmptyState title={candidates.length ? 'No matches' : 'No applications yet'}>
{candidates.length
? 'Try a different search, job, stage, or band filter.'
: unscoped
? 'Import a CV or add a candidate to see score, stage, and recruiter on this table.'
: requisitionScoped
? 'Candidates appear here when they are allocated to jobs opened from your requisitions.'
: 'Candidates appear here when they are allocated to a job you created.'}
</EmptyState>
</td>
</tr>
@ -575,6 +662,7 @@ function RecruiterCandidates() {
{c.source === 'Form' && (
<Badge className="b-gray" style={{ marginLeft: 6, fontSize: 10 }}>Form</Badge>
)}
<ReappliedBadge row={c} />
</div>
<div className="cell-sub">{c.email || '—'}</div>
</div>
@ -596,7 +684,7 @@ function RecruiterCandidates() {
</td>
<td>
<span className="text-sm">
{c.applied ? c.applied.toLocaleDateString() : '—'}
{c.applied ? fmtDate(c.applied) : '—'}
</span>
</td>
</tr>
@ -614,7 +702,7 @@ function RecruiterCandidates() {
setPage={(p) => setSkip((p - 1) * pageSize)}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={(n) => setPageSize(n)}
onPageSizeChange={(n) => { setPageSize(n); setSkip(0) }}
pageSizeMax={500}
/>
</div>
@ -679,9 +767,7 @@ const asList = (value) => (Array.isArray(value) ? value : [])
/** ISO stamp -> display date; ats_results.computed_at is a string, fmtDate takes a Date. */
function fmtStamp(value) {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : fmtDate(d)
return fmtDate(value) || null
}
/**
@ -934,6 +1020,30 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
referral: '',
})
const lookupEmail = (form.values.email || '').trim().toLowerCase()
const [debouncedEmail, setDebouncedEmail] = useState('')
useEffect(() => {
const timer = setTimeout(() => setDebouncedEmail(lookupEmail), 400)
return () => clearTimeout(timer)
}, [lookupEmail])
const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(debouncedEmail)
const priorQuery = useQuery({
queryKey: qk.candidates.applications(debouncedEmail),
queryFn: async () => {
const res = await candidatesApi.fetchApplicationHistory(debouncedEmail)
return res?.data ?? null
},
enabled: emailLooksValid,
staleTime: 30_000,
})
const priorHistory = priorQuery.data
const priorRow = priorHistory?.found
? {
is_reapplicant: Boolean(priorHistory.is_reapplicant),
previous_applications: Array.isArray(priorHistory.applications) ? priorHistory.applications : [],
}
: null
// Defaulting by derivation rather than in an effect: the picker resolves after
// first paint, and useFormState's setters are new every render, so seeding the
// field from an effect would either loop or need a ref to guard it.
@ -1046,6 +1156,25 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
{priorHistory?.found && priorRow?.previous_applications.length > 0 && (
<PreviousApplications row={priorRow} title="This email already applied" />
)}
{priorHistory?.found && !(priorRow?.previous_applications.length) && (
<div
className="card"
style={{
boxShadow: 'none',
background: 'var(--warning-soft)',
border: '1px solid var(--warning)',
marginBottom: 18,
}}
>
<div className="card-body text-sm">
This email already has a candidate account
{priorHistory.user?.name ? ` (${priorHistory.user.name})` : ''}.
</div>
</div>
)}
<div className="form-grid">
<div className="form-field">
<label>Full Name <span className="req">*</span></label>

View File

@ -21,6 +21,7 @@ import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import JobCandidates from './JobCandidates'
import { qk } from '../lib/queryKeys'
import { fmtDate } from '../lib/format'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as s3Api from '../api/s3'
@ -429,7 +430,7 @@ function CvBank() {
<div className="fw-600 text-sm">{r.file_name || 'CV'}</div>
<div className="cell-sub">
{r.candidate_email || 'No email detected'}
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
{r.created_at ? ` · added ${fmtDate(r.created_at)}` : ''}
</div>
{r.linkedin_url ? (
<div className="cell-sub" style={{ marginTop: 2 }}>

View File

@ -35,7 +35,8 @@ import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import { qk } from '../lib/queryKeys'
import { RANGES, rangeLabel, rangeWindow } from '../lib/timeRanges'
import { fmtShort, money } from '../data/seed'
import { fmtWeekdayDate, fmtShort } from '../lib/format'
import { money } from '../data/seed'
import * as activityApi from '../api/activity'
import * as analyticsApi from '../api/analytics'
import * as inboxApi from '../api/inbox'
@ -94,18 +95,11 @@ function greetingFor(now = new Date()) {
}
function formatDashDate(d = new Date()) {
return d.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})
return fmtWeekdayDate(d)
}
function fmtWhen(iso) {
if (!iso) return '—'
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? '—' : fmtShort(d)
return fmtShort(iso) || '—'
}
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {

View File

@ -26,13 +26,14 @@ import { useAuth } from '../auth/AuthContext'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import { exportStyledXlsx } from '../lib/exportXlsx'
import { formatRole } from '../lib/format'
import { formatRole, fmtDate, fmtDateTime, fmtTime, toDate } from '../lib/format'
import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory'
import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox'
import * as sheetApi from '../api/sheet'
import * as s3Api from '../api/s3'
import {
atsRecommendationClass, avatarColor, fmtDate, initials as initialsOf,
atsRecommendationClass, avatarColor, initials as initialsOf,
inboxSources, sourceMeta,
} from '../data/seed'
@ -141,44 +142,11 @@ const SERVER_SCOPED_TABS = new Set(TABS)
/**
* message_received_time / message_sent_time are plain string columns
* (backend/inbox/models.py:54-56), not timestamps. An unparseable value yields
* an Invalid Date that every fmt* helper renders as the literal "Invalid Date",
* so return null instead and let the call sites decide what to show.
* (backend/inbox/models.py:54-56), not timestamps. Unparseable values
* become null never "Invalid Date".
*/
function parseDate(value) {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
function startOfDay(d) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
/**
* Outlook-style list timestamps in the client's local timezone.
* Within 7 days: weekday + AM/PM time. Older: dd/mm/yyyy only like Outlook.
* The date-plus-time form was ~105px wide, and in the 380px queue rail that
* squeezed .ii-main to 148px: sender names painted over the timestamp and the
* meta chips wrapped one-per-line. The full timestamp is in the detail pane.
*/
function outlookListTime(value) {
if (!value) return '—'
const d = value instanceof Date ? value : new Date(value)
if (Number.isNaN(d.getTime())) return '—'
const daysAgo = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000)
if (daysAgo < 7) {
const weekday = d.toLocaleDateString(undefined, { weekday: 'short' })
const time = d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
return `${weekday} ${time}`
}
const dd = String(d.getDate()).padStart(2, '0')
const mm = String(d.getMonth() + 1).padStart(2, '0')
return `${dd}/${mm}/${d.getFullYear()}`
return toDate(value)
}
/**
@ -197,37 +165,97 @@ function sourceFrom(messageTo) {
return { source: raw.split(',')[0].trim(), sourceMeta: null }
}
/**
* Form `source_of_application` is a free-text label (LinkedIn, Indeed, ), not a
* To-address. Reuse the email source palette when the spelling matches; otherwise
* tag the row as a Sheet Forms entry so the chip still paints.
*/
function formSourceFrom(raw) {
const label = (raw || '').trim()
if (!label) return { source: 'Google Forms', sourceMeta: SHEET_SOURCE_META }
const flat = label.toLowerCase().replace(/[^a-z]/g, '')
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
return { source: label, sourceMeta: SHEET_SOURCE_META }
/** Sheet Forms always chip as Google Sheet — not the form's "where did you hear" answer. */
const FORM_LIST_SOURCE = { source: 'Google Sheet', sourceMeta: SHEET_SOURCE_META }
/** Google Form "Timestamp" cell — US M/D/YYYY, e.g. 8/12/2026 8:25:19 → 12th Aug. */
function rawFormTimestamp(row) {
const raw = row?.raw_record
if (!raw || typeof raw !== 'object') return null
for (const [key, val] of Object.entries(raw)) {
if (String(key).trim().toLowerCase() === 'timestamp' && val != null && String(val).trim()) {
return String(val).trim()
}
}
return null
}
/** entry_date is midnight UTC; entry_time is a separate "HH:MM" string from the sheet. */
function formReceivedAt(entryDate, entryTime) {
const d = parseDate(entryDate)
function parseFormTimestamp(value) {
if (value == null || value === '') return null
const s = String(value).trim()
const m = s.match(/^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{2,4})(?:[,\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?)?/i)
if (m) {
const a = Number(m[1])
const b = Number(m[2])
let year = Number(m[3])
if (year < 100) year += 2000
let month
let day
if (a > 12 && b <= 12) {
day = a
month = b
} else {
month = a
day = b
}
if (month < 1 || month > 12 || day < 1 || day > 31) return null
let hours = m[4] != null ? Number(m[4]) : 0
const minutes = m[5] != null ? Number(m[5]) : 0
const seconds = m[6] != null ? Number(m[6]) : 0
const ap = (m[7] || '').toLowerCase()
if (ap === 'pm' && hours < 12) hours += 12
if (ap === 'am' && hours === 12) hours = 0
const d = new Date(year, month - 1, day, hours, minutes, seconds)
if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return null
return d
}
return toDate(s)
}
/** Prefer the original sheet Timestamp so already-imported swapped dates still display right. */
function formReceivedAt(entryDate, entryTime, timestampRaw) {
const fromSheet = parseFormTimestamp(timestampRaw)
if (fromSheet) return fromSheet
const d = toDate(entryDate)
if (!d) return null
const m = String(entryTime || '').match(/(\d{1,2}):(\d{2})/)
if (m) d.setHours(Number(m[1]), Number(m[2]), 0, 0)
const m = String(entryTime || '').trim().match(/(\d{1,2}):(\d{2})\s*(am|pm)?/i)
if (m) {
let hours = Number(m[1])
const minutes = Number(m[2])
const ap = (m[3] || '').toLowerCase()
if (ap === 'pm' && hours < 12) hours += 12
if (ap === 'am' && hours === 12) hours = 0
d.setHours(hours, minutes, 0, 0)
}
return d
}
/** Same numeric gate as email `ats_score` / pipeline ScoreChip. */
function asAtsScore(value) {
if (value == null || value === '') return null
const n = Number(value)
return Number.isFinite(n) ? n : null
}
/**
* GET /sheet/form-data/fetch row the same list/detail shape the email channel
* uses for name / avatar / position / source / time, plus form-only profile fields.
* job_posts are title-matched (position_applied_for job_posts.title), not AI.
* job_posts come from suggested_job_post_ids (ILIKE title matches), not AI.
*/
function mapFormRow(row) {
const name = (row.name || row.candidate_email || 'Unknown').trim()
const jobPosts = Array.isArray(row.job_posts) ? row.job_posts : []
const atsResults = Array.isArray(row.ats_results) ? row.ats_results : []
const assignedId = row.assigned_job_post_id
? String(row.assigned_job_post_id)
: (row.job_post_id ? String(row.job_post_id) : null)
const assignedScore = atsResults.find((s) => String(s.job_post_id) === String(assignedId))
const fromResults = atsResults.map((s) => asAtsScore(s.overall_score)).filter((n) => n != null)
const fromJobs = jobPosts.map((p) => asAtsScore(p.overall_score)).filter((n) => n != null)
const atsScore = asAtsScore(row.ats_score)
?? asAtsScore(assignedScore?.overall_score)
?? (fromResults.length ? Math.max(...fromResults) : null)
?? (fromJobs.length ? Math.max(...fromJobs) : null)
const state = row.processing_state || 'unread'
return {
kind: 'form',
@ -238,8 +266,8 @@ function mapFormRow(row) {
email: row.candidate_email || '',
phone: row.candidate_number || '',
position: row.position_applied_for || '—',
...formSourceFrom(row.source_of_application),
received: formReceivedAt(row.entry_date, row.entry_time),
...FORM_LIST_SOURCE,
received: formReceivedAt(row.entry_date, row.entry_time, rawFormTimestamp(row)),
screenedBy: row.screened_by || '',
hrComments: row.hr_comments || '',
gender: row.gender || '',
@ -266,8 +294,15 @@ function mapFormRow(row) {
processingState: state,
duplicate: Boolean(row.is_duplicate),
jobPosts,
assignedId: row.job_post_id ? String(row.job_post_id) : null,
suggestedIds: Array.isArray(row.suggested_job_post_ids)
? row.suggested_job_post_ids.map(String)
: [],
atsResults,
atsScore,
assignedId,
assignedPost: row.assigned_job_post || null,
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
}
@ -407,6 +442,8 @@ async function fetchMessageDetail(recordId) {
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null,
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
}
@ -446,7 +483,7 @@ async function fetchApplications(params) {
linkedinUrl: row.linkedin_url || '',
// resume_text is not on the list payload (serialize_application
// light=True). The detail query fetches it for the open row.
atsScore: row.ats_score,
atsScore: asAtsScore(row.ats_score),
phone: row.phone,
experience: row.experience,
recruiter: row.recruiter,
@ -455,6 +492,8 @@ async function fetchApplications(params) {
? row.suggested_job_post_ids.map(String)
: [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
}),
total: Number(res?.total ?? rows.length) || 0,
@ -1053,7 +1092,19 @@ export default function Inbox() {
enabled: Boolean(selectedId),
})
const selectedRow = inbox.find((i) => i.id === selectedId)
const sidebar = useMemo(() => {
if (!isForms) return list
const detail = detailQuery.data
const detailScore = asAtsScore(detail?.atsScore)
if (!detail?.id || detailScore == null) return list
return list.map((row) => (
String(row.id) === String(detail.id)
? { ...row, atsScore: asAtsScore(row.atsScore) ?? detailScore }
: row
))
}, [isForms, list, detailQuery.data])
const selectedRow = sidebar.find((i) => i.id === selectedId)
const selected = selectedRow || detailQuery.data
? { ...selectedRow, ...(detailQuery.data ?? {}) }
: null
@ -1226,7 +1277,7 @@ export default function Inbox() {
{ header: 'Position', key: 'position', width: 34 },
{ header: 'Channel', key: 'channel', width: 12 },
{ header: 'Source', key: 'source', width: 20 },
{ header: 'Received', key: 'received', width: 12 },
{ header: 'Received', key: 'received', width: 16 },
{ header: 'Status', key: 'status', width: 12 },
{ header: 'City', key: 'city', width: 14 },
{ header: 'Notice period', key: 'notice', width: 13 },
@ -1237,7 +1288,7 @@ export default function Inbox() {
name: r.name, email: r.email, phone: r.phone, position: r.position,
channel: r.kind === 'form' ? 'Sheet Form' : 'Email',
source: r.source,
received: r.received ? r.received.toISOString().slice(0, 10) : '',
received: r.received ? fmtDate(r.received) : '',
status: r.processing, city: r.residingCity, notice: r.noticePeriod,
ats: r.atsScore, job: r.assignedPost?.title,
})),
@ -1468,7 +1519,7 @@ export default function Inbox() {
{isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'}
</EmptyState>
) : (
list.map((i) => (
sidebar.map((i) => (
<div
key={i.id}
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
@ -1488,18 +1539,21 @@ export default function Inbox() {
{i.duplicate && (
<span className="badge b-red badge-plain" style={{ padding: '1px 6px', fontSize: 10 }}>DUP</span>
)}
<ReappliedBadge row={i} />
</div>
<div className="ii-pos">{i.position}</div>
<div className="ii-aside">
<div className="ii-time">
{outlookListTime(i.received)}
{i.received ? (
<>
<div>{fmtDate(i.received)}</div>
<div>{fmtTime(i.received)}</div>
</>
) : '—'}
</div>
{i.atsScore != null && (
{asAtsScore(i.atsScore) != null && (
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
)}
{i.kind === 'form' && i.noticePeriod && (
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
)}
</div>
<div className="ii-meta">
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
@ -1672,7 +1726,7 @@ function firstResumeKey(item) {
/**
* Sheet form applicant detail profile grids + resume/LinkedIn links +
* title-matched job selection (position_applied_for job_posts.title) +
* ILIKE-matched job selection (one applied-for title many job posts) +
* the same Import / Shortlist / Duplicate / Reject actions as email.
*/
function FormApplicantDetail({
@ -1732,6 +1786,23 @@ function FormApplicantDetail({
const alreadyProcessed = i.processing === 'Processed'
const shortlistLocked = !alreadyProcessed && !jobChosen
const panelBusy = busy || assignMutation.isPending
const selectedAts = useMemo(() => {
const jobId = selection || i.assignedId
if (!jobId) return null
const fromPost = (i.jobPosts || []).find((p) => String(p.id) === String(jobId))
const fromResults = (i.atsResults || []).find((s) => String(s.job_post_id) === String(jobId))
const score = fromPost?.overall_score ?? fromResults?.overall_score
if (score == null || !Number.isFinite(Number(score))) return null
const n = Number(score)
return {
score: n,
band: fromPost?.band || fromResults?.band
|| (n >= 82 ? 'Strong Match' : n >= 65 ? 'Potential Match' : 'Weak Match'),
}
}, [selection, i.assignedId, i.jobPosts, i.atsResults])
const selectedRingColor = selectedAts == null
? undefined
: selectedAts.score >= 82 ? 'var(--success)' : selectedAts.score >= 65 ? 'var(--warning)' : 'var(--danger)'
async function handleMove() {
if (busy || alreadyProcessed) return
@ -1755,7 +1826,10 @@ function FormApplicantDetail({
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-name" style={{ fontSize: 19 }}>
{i.name}
<ReappliedBadge row={i} />
</div>
<div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
@ -1768,8 +1842,24 @@ function FormApplicantDetail({
{loading && <span className="cell-sub">Loading details</span>}
</div>
</div>
{i.rowNumber != null && (
<div style={{ textAlign: 'right' }}>
<div className="cell-sub">Sheet row</div>
<div className="fw-600">{i.rowNumber}</div>
</div>
)}
{selectedAts != null && (
<div style={{ textAlign: 'center' }}>
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': selectedAts.score, '--c': selectedRingColor }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{Math.round(selectedAts.score)}</div></div>
</div>
<div className="cell-sub" style={{ marginTop: 4 }}>{selectedAts.band || 'ATS Score'}</div>
</div>
)}
</div>
<PreviousApplications row={i} />
{(resumeHref || profileHref) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
{resumeHref && (
@ -1847,7 +1937,7 @@ function FormApplicantDetail({
<div className="info-grid">
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDate(i.received) : '—'}</div></div>
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{orDash(i.source)}</div></div>
<div className="info-item"><div className="il">Screened by</div><div className="iv">{orDash(i.screenedBy)}</div></div>
<div className="info-item"><div className="il">Notice period</div><div className="iv">{orDash(i.noticePeriod)}</div></div>
@ -2111,7 +2201,10 @@ function ApplicationDetail({
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-name" style={{ fontSize: 19 }}>
{i.name}
<ReappliedBadge row={i} />
</div>
<div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
@ -2138,6 +2231,8 @@ function ApplicationDetail({
)}
</div>
<PreviousApplications row={i} />
{(resumeKey || i.hasAttachment || profileHref) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
{(resumeKey || i.hasAttachment) && (
@ -2164,10 +2259,10 @@ function ApplicationDetail({
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div>
<div className="info-item">
<div className="il">Received</div>
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
<div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</div>
</div>
{i.sentAt && (
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDate(i.sentAt)}</div></div>
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDateTime(i.sentAt)}</div></div>
)}
{i.cc && <div className="info-item"><div className="il">CC</div><div className="iv">{i.cc}</div></div>}
{i.bcc && <div className="info-item"><div className="il">BCC</div><div className="iv">{i.bcc}</div></div>}

View File

@ -36,7 +36,8 @@ import * as candidatesApi from '../api/candidates'
import * as feedbackApi from '../api/feedback'
import { INTERVIEW_STATUSES, INTERVIEW_TYPES } from '../api/interviews'
import { byInboxId, useApplications } from '../lib/useApplications'
import { avatarColor, fmtShort, initials as initialsOf } from '../data/seed'
import { fmtShort, fmtTime } from '../lib/format'
import { avatarColor, initials as initialsOf } from '../data/seed'
const FETCH_TOP = 200
@ -48,7 +49,7 @@ function toInstant(date, time) {
}
function clock(d) {
return d ? d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) : '—'
return fmtTime(d) || '—'
}
function sameDay(a, b) {
@ -313,7 +314,7 @@ export default function Interviews() {
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load interviews">
{friendlyAuthError(listQuery.error, 'The server did not return interviews.')}
{' '}This screen needs the <code>candidates.view</code> permission.
{' '}This screen needs the <code>interviews.view</code> permission.
</EmptyState>
</div>
)}

View File

@ -33,6 +33,7 @@ import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import { platformLabel, platformOptions, platformService } from '../lib/platforms'
import * as jobPostsApi from '../api/jobPosts'
import { toDate } from '../lib/format'
import { fmtShort } from '../data/seed'
const POST_LIMIT = 300
@ -74,10 +75,10 @@ export default function JobBoard() {
status: row.status || 'draft',
link: row.buffer_external_link || null,
postId: row.buffer_post_id || null,
sentAt: row.buffer_sent_at ? new Date(row.buffer_sent_at) : null,
sentAt: toDate(row.buffer_sent_at),
error: row.buffer_error || null,
isActive: row.is_active,
created: row.created_at ? new Date(row.created_at) : null,
created: toDate(row.created_at),
createdBy: row.created_by_name || null,
location: row.location || null,
employmentType: row.employment_type || null,

View File

@ -19,6 +19,7 @@ import { Avatar, Badge, EmptyState, Icon, ProgressBar } from '../ui/primitives'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' }
@ -75,7 +76,10 @@ function CandidateCard({ c, onView }) {
<div className="cand-head">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div className="cand-id">
<div className="cand-name">{displayName(c.name)}</div>
<div className="cand-name">
{displayName(c.name)}
<ReappliedBadge row={c} />
</div>
<div className="cand-role">{c.currentTitle ?? '—'}</div>
</div>
<MiniRing score={c.aiScore} />
@ -147,7 +151,10 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
<div className="profile-hero">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div className="flex-1">
<div className="ph-name">{displayName(c.name)}</div>
<div className="ph-name">
{displayName(c.name)}
<ReappliedBadge row={c} />
</div>
<div className="ph-role">{roleLine}</div>
<div className="ph-tags">
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source ?? '—'}</Badge>
@ -172,6 +179,7 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
<div className="tab-pane active">
{tab === 'Overview' && (
<>
<PreviousApplications row={c} />
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Candidate</div><div className="iv">{displayName(c.name)}</div></div>
<div className="info-item"><div className="il">Current Title</div><div className="iv">{c.currentTitle ?? '—'}</div></div>

View File

@ -29,7 +29,8 @@ import * as assignmentsApi from '../api/assignments'
import * as tasksApi from '../api/tasks'
import * as usersApi from '../api/users'
import * as requisitionsApi from '../api/requisitions'
import { empTypes, fmtShort } from '../data/seed'
import { fmtDateTime, fmtShort, toDate } from '../lib/format'
import { empTypes } from '../data/seed'
// Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list).
const JOB_LIMIT = 100
@ -40,6 +41,14 @@ async function fetchJobs() {
return rows.map(jobsApi.toJobView)
}
function deptValue(j) {
return String(j.requisitionDepartment || j.department || '').trim()
}
function deptLabel(j) {
return deptValue(j) || '—'
}
function splitLines(text) {
return String(text || '')
.split('\n')
@ -192,7 +201,7 @@ export default function Jobs() {
})
const departmentOptions = useMemo(
() => [...new Set(jobs.map((j) => j.department).filter(Boolean))].sort(),
() => [...new Set(jobs.map(deptValue).filter(Boolean))].sort(),
[jobs],
)
const typeOptions = useMemo(
@ -203,12 +212,12 @@ export default function Jobs() {
const rows = useMemo(
() =>
jobs.filter((j) => {
if (dept && j.department !== dept) return false
if (dept && deptValue(j) !== dept) return false
if (status && j.status !== status) return false
if (type && j.type !== type) return false
if (q) {
const term = q.toLowerCase()
const hay = [j.title, j.department, j.recruiter, j.hiringManager, j.location]
const hay = [j.title, deptValue(j), j.recruiter, j.hiringManager, j.location]
.filter(Boolean)
.join(' ')
.toLowerCase()
@ -241,11 +250,11 @@ export default function Jobs() {
render: (j) => (
<>
<div className="cell-primary">{j.title}</div>
<div className="cell-sub">{j.department || '—'}</div>
<div className="cell-sub">{deptLabel(j)}</div>
</>
),
},
{ key: 'department', label: 'Department', sortable: true, render: (j) => j.department || '—' },
{ key: 'department', label: 'Department', sortable: true, sortValue: deptValue, render: (j) => deptLabel(j) },
{ key: 'location', label: 'Location', sortable: true, render: (j) => <span className="text-muted">{j.location || '—'}</span> },
{ key: 'type', label: 'Type', render: (j) => j.type ? <Badge className="b-gray">{j.type}</Badge> : '—' },
{ key: 'platform', label: 'Platform', sortable: true, sortValue: (j) => platformLabel(j.platform), render: (j) => j.platform ? <Badge className="b-gray">{platformLabel(j.platform)}</Badge> : '—' },
@ -407,10 +416,7 @@ const MAX_IMAGE_MB = 5
const ROLE_LABEL = { primary_recruiter: 'Recruiter', hiring_manager: 'Hiring manager' }
function fmtWhen(value) {
const d = value instanceof Date ? value : new Date(value ?? NaN)
if (Number.isNaN(d.getTime())) return null
const clock = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
return `${fmtShort(d)} · ${clock}`
return fmtDateTime(value) || null
}
/**
@ -1000,7 +1006,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
return (
<Modal
title="Edit Job"
subtitle={j.department || undefined}
subtitle={deptValue(j) || undefined}
size="modal-lg"
onClose={onClose}
footer={
@ -1227,7 +1233,7 @@ function JobHistory({ historyQuery, statusQuery }) {
...statusRows.map((row) => ({
kind: 'status',
id: `s-${row.id}`,
at: row.created_at ? new Date(row.created_at) : null,
at: toDate(row.created_at),
row,
})),
].sort((a, b) => (b.at?.getTime() || 0) - (a.at?.getTime() || 0))
@ -1342,7 +1348,7 @@ function JobDetail({
return (
<Modal
title="Job Details"
subtitle={j.department || undefined}
subtitle={deptValue(j) || undefined}
size="modal-lg"
onClose={onClose}
footer={
@ -1368,7 +1374,7 @@ function JobDetail({
</span>
<div>
<div style={{ fontSize: 19, fontWeight: 700 }}>{j.title}</div>
<div className="text-muted">{[j.department, j.location].filter(Boolean).join(' · ') || '—'}</div>
<div className="text-muted">{[deptValue(j), j.location].filter(Boolean).join(' · ') || '—'}</div>
</div>
<div style={{ marginLeft: 'auto' }}>
{canEdit ? (
@ -1398,7 +1404,7 @@ function JobDetail({
{tab === 'details' && (
<>
<div className="info-grid mb-18">
<div className="info-item"><div className="il">Department</div><div className="iv">{j.department || '—'}</div></div>
<div className="info-item"><div className="il">Department</div><div className="iv">{deptLabel(j)}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div>
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div>
<div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || '—'}</div></div>

View File

@ -25,6 +25,7 @@ import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import * as s3Api from '../api/s3'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
import { toDate } from '../lib/format'
const TABS = [
{ key: 'needs', label: 'Needs assignment' },
@ -42,12 +43,6 @@ const PAGE_SIZE_MAX = 500
const CV_BANK_META = { icon: 'file', color: 'var(--c2)', channel: 'Upload' }
function parseDate(value) {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
function mapRow(row) {
const name = row.name || row.email || row.file_name || 'Unknown'
return {
@ -59,7 +54,7 @@ function mapRow(row) {
position: row.file_name || 'CV bank',
source: 'CV bank',
sourceMeta: CV_BANK_META,
received: parseDate(row.created_at),
received: toDate(row.created_at),
resumeText: row.resume_text || '',
filePath: row.file_path || '',
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,

View File

@ -26,6 +26,7 @@ import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as jobPostsApi from '../api/jobPosts'
import * as pipelineApi from '../api/pipeline'
import { ReappliedBadge } from '../components/ReapplicantHistory'
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
export const KANBAN_STAGES = [
@ -286,6 +287,7 @@ export default function Pipeline() {
{c.source === 'Form' && (
<Badge className="b-gray" style={{ fontSize: 10 }}>Form</Badge>
)}
<ReappliedBadge row={c} />
</div>
<div className="kc-role">{c.currentTitle}</div>
</div>

View File

@ -10,13 +10,12 @@ import { useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../ui/PageHeader'
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
import { Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as jobStatsApi from '../api/jobStats'
const PAGE_SIZE = 10
const STAGES = [
{ key: 'shortlist', label: 'Shortlisted', tone: 'blue' },
{ key: 'screened', label: 'Screened', tone: 'purple' },
@ -209,7 +208,8 @@ export default function Progress() {
const [selectedId, setSelectedId] = useState(deepLinkJobId)
const [query, setQuery] = useState('')
const [status, setStatus] = useState('all')
const [page, setPage] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const statsQuery = useQuery({
queryKey: qk.jobs.stats({ top: 500, skip: 0 }),
@ -240,20 +240,27 @@ export default function Progress() {
.sort((a, b) => (b.total - a.total) || a.title.localeCompare(b.title))
}, [jobs, query, status])
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const safePage = Math.min(page, pageCount - 1)
const pageRows = filtered.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE)
const pages = Math.max(1, Math.ceil(filtered.length / pageSize))
const currentPage = Math.min(page, pages)
const start = (currentPage - 1) * pageSize
const pageRows = filtered.slice(start, start + pageSize)
const from = filtered.length ? start + 1 : 0
const to = Math.min(start + pageSize, filtered.length)
useEffect(() => {
setPage(0)
setPage(1)
}, [query, status])
useEffect(() => {
setPage((p) => pageAfterSizeChange(p, filtered.length, pageSize))
}, [pageSize, filtered.length])
// Deep-link: once jobs load, jump the sidebar page to that role.
useEffect(() => {
if (!deepLinkJobId || !filtered.length) return
const idx = filtered.findIndex((j) => String(j.id) === String(deepLinkJobId))
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE))
}, [deepLinkJobId, filtered])
if (idx >= 0) setPage(Math.floor(idx / pageSize) + 1)
}, [deepLinkJobId, filtered, pageSize])
useEffect(() => {
if (!jobs.length) {
@ -274,7 +281,7 @@ export default function Progress() {
const next = String(id || '')
setSelectedId(next)
const idx = filtered.findIndex((j) => String(j.id) === next)
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE))
if (idx >= 0) setPage(Math.floor(idx / pageSize) + 1)
setSearchParams((prev) => {
const nextParams = new URLSearchParams(prev)
if (next) nextParams.set('job', next)
@ -290,8 +297,6 @@ export default function Progress() {
const totalApplicants = sumField(jobs, 'total')
const activeRoles = jobs.filter((j) => String(j.requisitionStatus || '').toLowerCase() === 'open').length
const rangeStart = filtered.length ? safePage * PAGE_SIZE + 1 : 0
const rangeEnd = Math.min(filtered.length, safePage * PAGE_SIZE + PAGE_SIZE)
return (
<div className="page progress-page">
@ -377,29 +382,19 @@ export default function Progress() {
)}
</div>
<div className="progress-sidebar-pager">
<span>
{rangeStart}{rangeEnd} of {filtered.length}
</span>
<div className="progress-pager-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={safePage <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Previous
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={safePage >= pageCount - 1}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
Next
</button>
</div>
</div>
{filtered.length > 0 && (
<Pagination
from={from}
to={to}
total={filtered.length}
page={currentPage}
pages={pages}
setPage={setPage}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={setPageSize}
/>
)}
</aside>
<main className="progress-main">

View File

@ -7,13 +7,11 @@
now come from GET /permission-tags/fetch, so a 105th tag appears here without
a frontend change, and a renamed module cannot silently shift every column.
THE SAVE BUTTON IS STILL NOT A PER-CELL TOGGLE, AND THAT IS DELIBERATE. The
backend grants access through BUNDLES `roles.permissions` is a list of
permission-bundle ids, and `effective_permissions` is the resolved union. An
arbitrary per-tag set is not expressible through PUT /roles/update, so the
matrix stays read-only and the editable thing is the bundle set, which is
what actually determines access. Editing bundles writes real permissions;
a per-cell grid would have to lie about what it saved.
Tick a cell to grant or revoke that module.action tag, then Save. Save
writes the exact set onto a per-role overlay bundle (PUT /roles/matrix/update)
so shared system bundles are not mutated. The Edit modal still assigns
named bundles; the next Save of this grid replaces the role's bundle list
with that overlay.
Delete is soft server-side and refuses system roles (Role.delete_role), so
the button is hidden on those rather than offered and rejected.
@ -26,6 +24,7 @@ import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { useFormState } from '../components/AuthLayout'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
@ -43,13 +42,26 @@ function humanise(slug) {
.join(' ')
}
/** Extra tooltip copy for tags whose matrix cell is an opt-in, not a screen. */
const TAG_HELP = {
'requisitions.configure':
'Limit Jobs and Candidates to requisitions this user created. Independent of Create. Untick to use the default recruiter list.',
'candidates.manage':
'See every candidate, not only jobs this user owns.',
'requisitions.manage':
'Org-wide requisition list (admin).',
}
export default function Rbac() {
const { toast } = useToast()
const { can } = useAuth()
const qc = useQueryClient()
const [selectedId, setSelectedId] = useState(null)
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState(null)
const [confirmDelete, setConfirmDelete] = useState(null)
const [draft, setDraft] = useState(() => new Set())
const canEdit = can('rbac_users.edit')
const rolesQuery = useQuery({
queryKey: qk.roles.list(),
@ -95,6 +107,38 @@ export default function Rbac() {
const role = roles.find((r) => r.id === selectedId) ?? roles[0]
const granted = useMemo(() => new Set(role?.effective_permissions ?? []), [role])
const grantKey = (role?.effective_permissions ?? []).slice().sort().join('\0')
useEffect(() => {
setDraft(new Set(role?.effective_permissions ?? []))
}, [role?.id, grantKey])
const tagIdByName = useMemo(() => {
const map = new Map()
for (const t of tags) {
const name = t.tag_name || (t.module && t.action ? `${t.module}.${t.action}` : null)
if (name && t.id != null) map.set(name, t.id)
}
return map
}, [tags])
const dirty = useMemo(() => {
if (draft.size !== granted.size) return true
for (const tag of draft) {
if (!granted.has(tag)) return true
}
return false
}, [draft, granted])
const toggleTag = (tag) => {
if (!canEdit) return
setDraft((prev) => {
const next = new Set(prev)
if (next.has(tag)) next.delete(tag)
else next.add(tag)
return next
})
}
const invalidate = () => qc.invalidateQueries({ queryKey: qk.roles.all() })
@ -129,6 +173,27 @@ export default function Rbac() {
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the role.'), 'error'),
})
const saveMatrix = useMutation({
mutationFn: ({ id, permissionTags }) => rolesApi.updateRoleMatrix(id, permissionTags),
onSuccess: (res) => {
const next = res?.data?.effective_permissions
if (Array.isArray(next)) setDraft(new Set(next))
invalidate()
toast('Permissions saved', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not save permissions.'), 'error'),
})
const saveRoleMatrix = () => {
if (!role || !canEdit) return
const permissionTags = []
for (const name of draft) {
const id = tagIdByName.get(name)
if (id != null) permissionTags.push(id)
}
saveMatrix.mutate({ id: role.id, permissionTags })
}
const totalTags = tags.length
return (
@ -226,12 +291,14 @@ export default function Rbac() {
<div className="card-body">
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
<Icon name="lock" /> These are the roles <b>resolved</b> permissions. Access is granted
through bundles
Tick a module action to grant it, then <b>Save</b>. Grants are stored on this
roles overlay bundle
{role.bundles?.length
? `${role.bundles.map((b) => b.name ?? b).join(', ')}`
: ' — none assigned yet'}
. Edit the bundle set to change what this role can do.
? ` (currently ${role.bundles.map((b) => b.name ?? b).join(', ')})`
: ''}
. Shared system bundles are not rewritten.{' '}
<b>Requisitions Configure</b> limits Jobs and Candidates to
requisitions that user created; it is not implied by Create.
</p>
{tagsQuery.isPending && (
@ -245,6 +312,7 @@ export default function Rbac() {
</EmptyState>
)}
{tagsQuery.isSuccess && modules.length > 0 && (
<>
<div className="table-wrap">
<table className="rbac-matrix">
<thead>
@ -262,16 +330,21 @@ export default function Rbac() {
if (!tagSet.has(tag)) {
return <td key={action}><span className="text-muted">·</span></td>
}
const on = granted.has(tag)
const on = draft.has(tag)
const help = TAG_HELP[tag]
return (
<td key={action}>
<span
<button
type="button"
className={`perm-check${on ? ' on' : ''}`}
title={tag}
title={help ? `${tag}${help}` : tag}
disabled={!canEdit || saveMatrix.isPending}
aria-pressed={on}
aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`}
onClick={() => toggleTag(tag)}
>
<Icon name="check" />
</span>
</button>
</td>
)
})}
@ -280,6 +353,17 @@ export default function Rbac() {
</tbody>
</table>
</div>
<div className="rbac-save">
<button
type="button"
className="btn btn-primary"
disabled={!canEdit || !dirty || saveMatrix.isPending}
onClick={saveRoleMatrix}
>
<Icon name="check" /> {saveMatrix.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</>
)}
</div>
</>

View File

@ -46,6 +46,7 @@ import * as analyticsApi from '../api/analytics'
import * as costsApi from '../api/costs'
import * as jobsApi from '../api/jobs'
import * as reportsApi from '../api/reports'
import { fmtDate, fmtDateTime, toDate } from '../lib/format'
import { money } from '../data/seed'
const DEPT_CAP = 12
@ -108,7 +109,7 @@ function reportWindowLabel(filters) {
function runSubtitle(result) {
const win = result?.window || {}
const fmt = (v) => (v ? new Date(v).toLocaleDateString() : null)
const fmt = (v) => (v ? fmtDate(v) || null : null)
const from = fmt(win.from_date)
const to = fmt(win.to_date)
const range = from || to ? `${from ?? '…'} ${to ?? 'now'}` : 'All time'
@ -383,9 +384,9 @@ export default function Reports() {
{ key: '_window', label: 'Window', render: (r) => reportWindowLabel(r.filters) },
{
key: 'last_run_at', label: 'Last Run', sortable: true,
sortValue: (r) => (r.last_run_at ? new Date(r.last_run_at).getTime() : 0),
sortValue: (r) => toDate(r.last_run_at)?.getTime() ?? 0,
render: (r) => (r.last_run_at
? new Date(r.last_run_at).toLocaleString()
? fmtDateTime(r.last_run_at)
: <span className="text-muted">never</span>),
},
{

View File

@ -21,7 +21,7 @@ import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as requisitionsApi from '../api/requisitions'
import { EMPLOYMENT_TYPES, EMPLOYMENT_TYPE_LABEL, approvalStatus } from '../api/requisitions'
import { fmtShort } from '../data/seed'
import { fmtShort, toDateInput } from '../lib/format'
const TYPE_BADGE = {
permanent: 'b-indigo',
@ -36,12 +36,6 @@ const STATUS_BADGE = {
open: 'b-indigo',
}
function toDateInput(value) {
if (!value) return ''
const d = new Date(value)
return Number.isNaN(d.getTime()) ? String(value).slice(0, 10) : d.toISOString().slice(0, 10)
}
function emptyToNull(value) {
if (value === '' || value == null) return null
return value
@ -143,7 +137,7 @@ export default function Requisitions() {
sortValue: (r) => r.position?.date_needed || '',
render: (r) => (
<span className="text-muted">
{r.position?.date_needed ? fmtShort(new Date(r.position.date_needed)) : '—'}
{r.position?.date_needed ? fmtShort(r.position.date_needed) : '—'}
</span>
),
},
@ -155,7 +149,7 @@ export default function Requisitions() {
render: (r) => (
<>
<div className="cell-primary text-sm">{r.initiated_by || '—'}</div>
<div className="cell-sub">{r.initiated_date ? fmtShort(new Date(r.initiated_date)) : ''}</div>
<div className="cell-sub">{r.initiated_date ? fmtShort(r.initiated_date) : ''}</div>
</>
),
},

View File

@ -55,9 +55,7 @@ function formatExperience(value, unit) {
/** ats_results.computed_at is an ISO string; fmtDate takes a Date. */
function fmtStamp(value) {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : fmtDate(d)
return fmtDate(value) || null
}
function useCandidateDetail(userId) {

View File

@ -1,8 +1,10 @@
/* ============================================================
Settings org settings tabs persist via GET/PUT /org-settings/*.
Users + Appearance stay as before. Email Templates stay decorative
(explicitly out of Section C wiring scope). The Permissions tab remains
chrome; Access Control is the authoritative RBAC surface.
Excluding Universities / Companies are CRUD lists on exclude_university
and exclude_company (not org_settings key/value). Users + Appearance stay
as before. Email Templates stay decorative (explicitly out of Section C
wiring scope). The Permissions tab remains chrome; Access Control is the
authoritative RBAC surface.
============================================================ */
import { useEffect, useMemo, useRef, useState } from 'react'
@ -18,7 +20,7 @@ import { useFormState } from '../components/AuthLayout'
import { usePermission } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import { formatRole } from '../lib/format'
import { formatRole, fmtDate } from '../lib/format'
import * as rolesApi from '../api/roles'
import * as usersApi from '../api/users'
import * as orgSettingsApi from '../api/orgSettings'
@ -35,6 +37,7 @@ function humaniseSlug(slug) {
const TABS = [
'General', 'Users', 'Approvals', 'Permissions', 'Notifications',
'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance',
'Excluding Universities', 'Excluding Companies',
]
const ORG_TABS = new Set(['General', 'Notifications', 'Career Portal', 'Branding', 'Security'])
@ -140,6 +143,7 @@ export default function Settings() {
<Tabs
value={tab}
onChange={setTab}
className="tabs settings-tabs"
tabs={TABS.map((t) => ({
key: t,
label: t,
@ -158,6 +162,8 @@ export default function Settings() {
{tab === 'Branding' && <Branding registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Security' && <Security registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Appearance' && <Appearance />}
{tab === 'Excluding Universities' && <ExcludeUniversities />}
{tab === 'Excluding Companies' && <ExcludeCompanies />}
</div>
</div>
)
@ -331,7 +337,7 @@ function Users() {
{!u.is_active ? 'Pending' : u.is_approved ? 'Active' : 'Awaiting approval'}
</Badge>
</td>
<td className="text-muted">{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</td>
<td className="text-muted">{u.created_at ? fmtDate(u.created_at) : '—'}</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button
@ -427,7 +433,7 @@ function Approvals() {
</div>
</td>
<td><Badge className="b-indigo">{formatRole(u.role_name) || 'No role'}</Badge></td>
<td className="text-muted">{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</td>
<td className="text-muted">{u.created_at ? fmtDate(u.created_at) : '—'}</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-primary btn-sm"
@ -1219,3 +1225,207 @@ function Appearance() {
</div>
)
}
function ExcludeUniversities() {
return (
<ExclusionList
title="Excluded Universities"
subtitle="Talent sourcing skips profiles whose education matches a name on this list"
emptyTitle="No universities excluded"
emptyBody="Add a university name. Matching is case-insensitive and substring-based, so “NUST” also matches “NUST Islamabad”."
nameLabel="University name"
namePlaceholder="e.g. National University of Sciences and Technology"
queryKey={qk.orgSettings.universities()}
listFn={orgSettingsApi.listUniversities}
createFn={(name) => orgSettingsApi.createUniversity({ name })}
removeFn={orgSettingsApi.removeUniversity}
addedNoun="university"
/>
)
}
function ExcludeCompanies() {
return (
<ExclusionList
title="Excluded Companies"
subtitle="Talent sourcing skips people currently at these companies. Optional LinkedIn URLs stop those profiles being scraped at all."
emptyTitle="No companies excluded"
emptyBody="Add a company name. Matching is case-insensitive and substring-based, so “Utopia Brands” also matches “Utopia Brands Pakistan”."
nameLabel="Company name"
namePlaceholder="e.g. Utopia Brands"
extraLabel="LinkedIn company URL"
extraPlaceholder="https://www.linkedin.com/company/…"
extraKey="linkedin_url"
extraHeader="LinkedIn URL"
queryKey={qk.orgSettings.companies()}
listFn={orgSettingsApi.listCompanies}
createFn={(name, extra) => orgSettingsApi.createCompany({ name, linkedin_url: extra || null })}
removeFn={orgSettingsApi.removeCompany}
addedNoun="company"
/>
)
}
function ExclusionList({
title, subtitle, emptyTitle, emptyBody,
nameLabel, namePlaceholder,
extraLabel, extraPlaceholder, extraKey, extraHeader,
queryKey, listFn, createFn, removeFn, addedNoun,
}) {
const { toast } = useToast()
const { can } = usePermission()
const qc = useQueryClient()
const canConfigure = can('settings.configure')
const [name, setName] = useState('')
const [extra, setExtra] = useState('')
const query = useQuery({
queryKey,
queryFn: () => listFn().then((r) => r.data ?? []),
})
const rows = query.data ?? []
const create = useMutation({
mutationFn: () => createFn(name.trim(), extra.trim()),
onSuccess: () => {
qc.invalidateQueries({ queryKey })
setName('')
setExtra('')
toast(`${addedNoun[0].toUpperCase()}${addedNoun.slice(1)} excluded`, 'success')
},
onError: (err) => toast(friendlyAuthError(err, `Could not add this ${addedNoun}.`), 'error'),
})
const remove = useMutation({
mutationFn: (id) => removeFn(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey })
toast(`Removed from exclusion list`, 'success')
},
onError: (err) => toast(friendlyAuthError(err, `Could not remove this ${addedNoun}.`), 'error'),
})
function submit() {
if (!name.trim() || create.isPending) return
create.mutate()
}
if (query.isPending) {
return (
<div className="card"><div className="card-body">
<EmptyState icon="settings" title="Loading…">Fetching the exclusion list.</EmptyState>
</div></div>
)
}
if (query.isError) {
return (
<div className="card"><div className="card-body">
<EmptyState icon="settings" title="Couldnt load exclusions">
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
</EmptyState>
</div></div>
)
}
return (
<div className="card">
<div className="card-head">
<div>
<h3>{title}</h3>
<span className="ch-sub">{subtitle}</span>
</div>
</div>
<div className="card-body">
<form
noValidate
onSubmit={(e) => { e.preventDefault(); submit() }}
style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}
>
<div className="form-field" style={{ flex: '1 1 220px' }}>
<label htmlFor={`ex-${addedNoun}-name`}>{nameLabel}</label>
<input
id={`ex-${addedNoun}-name`}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={namePlaceholder}
disabled={!canConfigure || create.isPending}
/>
</div>
{extraKey && (
<div className="form-field" style={{ flex: '1 1 260px' }}>
<label htmlFor={`ex-${addedNoun}-extra`}>{extraLabel}</label>
<input
id={`ex-${addedNoun}-extra`}
type="url"
value={extra}
onChange={(e) => setExtra(e.target.value)}
placeholder={extraPlaceholder}
disabled={!canConfigure || create.isPending}
/>
</div>
)}
<div className="form-field">
<button
type="submit"
className="btn btn-primary"
disabled={!canConfigure || create.isPending || !name.trim()}
>
<Icon name="plus" /> {create.isPending ? 'Adding…' : 'Add'}
</button>
</div>
</form>
{!canConfigure && (
<div className="alert alert-danger" style={{ marginTop: 14 }}>
Adding or removing entries requires <code>settings.configure</code>.
</div>
)}
</div>
{rows.length === 0 ? (
<div className="card-body" style={{ paddingTop: 0 }}>
<EmptyState icon="settings" title={emptyTitle}>{emptyBody}</EmptyState>
</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>{nameLabel}</th>
{extraHeader && <th>{extraHeader}</th>}
<th>Added</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>
<div className="cell-primary">{row.name}</div>
</td>
{extraHeader && (
<td className="text-muted">
{row[extraKey] ? (
<a href={row[extraKey]} target="_blank" rel="noreferrer">{row[extraKey]}</a>
) : '—'}
</td>
)}
<td className="text-muted">{row.created_at ? fmtDate(row.created_at) : '—'}</td>
<td style={{ textAlign: 'right' }}>
<button
className="act-btn danger"
aria-label={`Remove ${row.name}`}
disabled={!canConfigure || (remove.isPending && remove.variables === row.id)}
onClick={() => remove.mutate(row.id)}
>
<Icon name="trash" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@ -160,7 +160,7 @@ function AppliedBadge({ applied }) {
const tip = [
applied.candidate,
applied.status ? `status ${applied.status}` : null,
applied.applied_at ? `applied ${new Date(applied.applied_at).toLocaleDateString()}` : null,
applied.applied_at ? `applied ${fmtDate(applied.applied_at)}` : null,
applied.applications > 1 ? `${applied.applications} applications` : null,
].filter(Boolean).join(' · ')
return (

View File

@ -46,6 +46,8 @@ import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import * as pipelineApi from '../api/pipeline'
import { ReappliedBadge } from '../components/ReapplicantHistory'
import { fmtDate } from '../lib/format'
import { avatarColor, initials as initialsOf } from '../data/seed'
/** Backend GET /candidate/fetch caps `limit` at 100. */
@ -111,6 +113,7 @@ function merge(row, template) {
// for the seed-only profile modal, but the filter reads `departments`.
departments,
department: departments[0] || template.department,
jobIds: candidatesApi.jobIdsOf(row),
// Prefer real Form / platform tags from manual_upload; seed only as fallback.
source: row.source || template.source,
// NO seed fallback. `ai_score` is the candidate's current ats_results row,
@ -119,6 +122,8 @@ function merge(row, template) {
// a recruiter would read as a real match.
aiScore: row.ai_score ?? null,
recommendation: row.recommendation ?? null,
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
}
@ -144,6 +149,7 @@ export default function TalentPool() {
const [q, setQ] = useState('')
const [dept, setDept] = useState('')
const [jobId, setJobId] = useState('')
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
@ -157,8 +163,8 @@ export default function TalentPool() {
}
const query = useQuery({
queryKey: qk.candidates.list({ limit: pageSize }),
queryFn: () => candidatesApi.list({ limit: pageSize }),
queryKey: qk.candidates.list({ limit: pageSize, assignedJobPostId: jobId || undefined }),
queryFn: () => candidatesApi.list({ limit: pageSize, assignedJobPostId: jobId || undefined }),
})
const deptsQuery = useQuery({
queryKey: qk.jobPosts.departments(),
@ -168,6 +174,20 @@ export default function TalentPool() {
},
})
const departments = deptsQuery.data ?? []
const jobsQuery = useQuery({
queryKey: qk.jobPosts.list({ top: 100, scope: 'talent-pool' }),
queryFn: async () => {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows
.filter((row) => row && row.id != null)
.map((row) => ({
id: String(row.id),
title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled',
}))
},
})
const jobs = jobsQuery.data ?? []
const pool = useMemo(
() => buildPool(candidatesApi.toRows(query.data), templates),
@ -231,7 +251,7 @@ export default function TalentPool() {
/* CSV of the FILTERED grid, built client-side there is no /candidate export
endpoint (jobs and reports each own theirs). Exporting `list` rather than
`pool` means the file always matches what the recruiter is looking at,
search and department filter included. Company/skills are seed-overlay
search, job, and department filter included. Company/skills are seed-overlay
values, same as the cards render. */
async function exportCsv() {
if (!list.length) {
@ -242,7 +262,7 @@ export default function TalentPool() {
await exportStyledXlsx({
filename: `talent-pool-${new Date().toISOString().slice(0, 10)}`,
title: 'Talent Pool',
subtitle: `${list.length} candidate${list.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
subtitle: `${list.length} candidate${list.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`,
columns: [
{ header: 'Name', key: 'name', width: 24 },
{ header: 'Email', key: 'email', width: 28 },
@ -287,6 +307,12 @@ export default function TalentPool() {
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" />
</div>
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option>
{jobs.map((j) => (
<option key={j.id} value={j.id}>{j.title}</option>
))}
</select>
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
<option value="">All Departments</option>
{departments.map((d) => <option key={d} value={d}>{d}</option>)}
@ -315,7 +341,7 @@ export default function TalentPool() {
) : query.isPending ? (
<EmptyState title="Loading talent pool…">Fetching candidates.</EmptyState>
) : (
<EmptyState title="No talent found">Try a different search or department.</EmptyState>
<EmptyState title="No talent found">Try a different search, job, or department.</EmptyState>
)}
</div>
) : (
@ -330,7 +356,10 @@ export default function TalentPool() {
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="lr-title">{c.name}</div>
<div className="lr-title">
{c.name}
<ReappliedBadge row={c} />
</div>
<div className="lr-sub">{c.currentTitle}</div>
</div>
{c.aiScore != null && <ScoreChip score={c.aiScore} />}

View File

@ -1020,7 +1020,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.ii-name { font-weight: 600; font-size: var(--fs-base); display: flex; align-items: center; gap: 6px; min-width: 0; }
.ii-pos { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ii-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }
.ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; }
.ii-time { font-size: 11px; color: var(--text-3); line-height: 1.35; }
/* Inbox sidebar only: fit the list instead of scrolling sideways.
Username (.ii-name) and subject (.ii-pos) are left alone.
The list column yields (34%, floor 280px) instead of holding a hard 420px,
@ -1376,10 +1376,12 @@ canvas { width: 100%; max-width: 100%; display: block; }
.rbac-matrix th:first-child { text-align: left; padding-left: 16px; }
.rbac-matrix td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: center; }
.rbac-matrix td:first-child { text-align: left; padding-left: 16px; font-weight: 600; }
.perm-check { width: 22px; height: 22px; border-radius: 6px; border: 2px solid var(--border-strong); display: inline-grid; place-items: center; cursor: pointer; transition: .12s; }
.perm-check { width: 22px; height: 22px; border-radius: 6px; border: 2px solid var(--border-strong); display: inline-grid; place-items: center; cursor: pointer; transition: .12s; background: transparent; padding: 0; font: inherit; color: inherit; }
.perm-check.on { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
.perm-check.on svg { width: 13px; height: 13px; }
.perm-check:not(.on) svg { display: none; }
.perm-check:disabled { cursor: not-allowed; opacity: 0.55; }
.rbac-save { display: flex; justify-content: flex-end; margin-top: 16px; }
/* AI Assistant chat */
.chat-wrap { display: flex; flex-direction: column; height: calc(100vh - 190px); height: calc(100dvh - 190px); }
@ -1852,6 +1854,13 @@ canvas { width: 100%; max-width: 100%; display: block; }
ten tabs do not fit the 860px profile modal. */
.tabs-wrap { flex-wrap: wrap; overflow-x: visible; }
/* Settings: keep every tab on a single row; scroll sideways instead of wrapping
so Excluding Companies does not drop onto its own leftover bar. */
.settings-tabs {
flex-wrap: nowrap;
overflow-x: auto;
}
/* Full-page candidate profile (/candidate/:userId).
The page centers itself with a generous cap so ultrawide monitors don't get
a mile-wide form, and everything below the cap is fluid no fixed widths. */
@ -1943,11 +1952,20 @@ canvas { width: 100%; max-width: 100%; display: block; }
.progress-status-dot.tone-success { background: var(--success); }
.progress-status-dot.tone-warning { background: var(--warning); }
.progress-sidebar-pager {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 12px; border-top: 1px solid var(--border); font-size: var(--fs-sm); color: var(--text-2);
.progress-sidebar .pagination {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 10px 12px;
overflow: visible;
}
.progress-sidebar .page-info { width: 100%; }
.progress-sidebar .page-controls { flex-wrap: wrap; }
.progress-sidebar .page-nav {
justify-content: flex-start;
flex: 1 1 100%;
width: 100%;
}
.progress-pager-actions { display: flex; gap: 8px; }
.progress-main { padding: 20px; min-width: 0; color: var(--text); }
.progress-detail { display: flex; flex-direction: column; gap: 20px; }

View File

@ -10,7 +10,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Modal from './Modal'
import { Badge, EmptyState, Icon } from './primitives'
import { Badge, EmptyState, Icon, ScoreChip } from './primitives'
import { friendlyAuthError } from '../lib/errors'
import { qk } from '../lib/queryKeys'
import * as jobPostsApi from '../api/jobPosts'
@ -74,6 +74,7 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
) : (
<Badge>{post.status || 'draft'}</Badge>
)}
{post?.overall_score != null && <ScoreChip score={post.overall_score} />}
{selected && <Icon name="check-circle" />}
</div>
{meta && <div className="cell-sub">{meta}</div>}