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.jsxpull/73/head^2
commit
05ca7bce8f
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) == []
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 : []
|
||||
|
|
|
|||
|
|
@ -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 : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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="Couldn’t 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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="Couldn’t 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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 role’s <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
|
||||
role’s 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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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="Couldn’t 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
Loading…
Reference in New Issue