is_approved, and file_pat aded

pull/32/head
ahmed.mujtaba 2026-08-28 19:36:22 +05:00
parent 7921b54141
commit e4c5ffec20
31 changed files with 568 additions and 125 deletions

View File

@ -29,12 +29,14 @@ from pathlib import Path
from typing import Any, AsyncIterator, Callable, Sequence from typing import Any, AsyncIterator, Callable, Sequence
from alembic import command from alembic import command
from alembic.autogenerate import compare_metadata, produce_migrations from alembic.autogenerate import compare_metadata, produce_migrations, render_python_code
from alembic.config import Config from alembic.config import Config
from alembic.operations import Operations from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory from alembic.script import ScriptDirectory
from alembic.script.revision import ResolutionError from alembic.script.revision import ResolutionError
from alembic.util import rev_id as new_rev_id
from alembic.util.exc import CommandError
from sqlalchemy import MetaData, text from sqlalchemy import MetaData, text
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
@ -185,6 +187,11 @@ MANUAL_TABLE = "manual_migrations"
def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to: Any) -> bool: def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to: Any) -> bool:
"""Keep autogenerate inside the schemas this application owns.""" """Keep autogenerate inside the schemas this application owns."""
s = get_settings() s = get_settings()
if type_ == "foreign_key_constraint":
# Reflected FKs are unqualified (search_path=app) while models use
# app.table; Alembic reports every FK as drop+add. Real FK changes
# go through models + migrate/manual SQL, not autogenerate.
return False
if type_ != "table": if type_ != "table":
return True return True
if name in (VERSION_TABLE, MANUAL_TABLE): # migration bookkeeping; never ours to alter if name in (VERSION_TABLE, MANUAL_TABLE): # migration bookkeeping; never ours to alter
@ -254,7 +261,8 @@ def _revision_on_disk(revision_id: str) -> bool:
"""True when `revision_id` exists under migrations/versions (or is a known alias).""" """True when `revision_id` exists under migrations/versions (or is a known alias)."""
try: try:
ScriptDirectory.from_config(config()).get_revision(revision_id) ScriptDirectory.from_config(config()).get_revision(revision_id)
except ResolutionError: except (ResolutionError, CommandError):
# ScriptDirectory.get_revision wraps ResolutionError in CommandError.
return False return False
return True return True
@ -365,6 +373,30 @@ async def apply_model_drift() -> bool:
return applied > 0 return applied > 0
def _write_revision(connection: Connection, message: str) -> str | None:
"""Write a versions/*.py file from the current ORM→DB diff.
Uses the on-disk head as down_revision and never reads alembic_version, so
a stamp from another machine (versions are gitignored) cannot block
makemigrations. The live database bookmark is left unchanged.
"""
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
ctx = MigrationContext.configure(connection, opts=opts)
script = produce_migrations(ctx, target_metadata())
if script.upgrade_ops.is_empty():
return None
script_dir = ScriptDirectory.from_config(config(connection))
revid = new_rev_id()
script_dir.generate_revision(
revid,
message,
head=script_dir.get_current_head() or "base",
upgrades=render_python_code(script.upgrade_ops, migration_context=ctx),
downgrades=render_python_code(script.downgrade_ops, migration_context=ctx),
)
return revid
async def autogenerate(message: str = "auto") -> str | None: async def autogenerate(message: str = "auto") -> str | None:
"""Write a revision if the models have drifted; return its id, or None. """Write a revision if the models have drifted; return its id, or None.
@ -379,10 +411,15 @@ async def autogenerate(message: str = "auto") -> str | None:
logger.info("schema matches the models") logger.info("schema matches the models")
return None return None
logger.info("%s schema difference(s) detected", len(diffs)) logger.info("%s schema difference(s) detected", len(diffs))
before = head() current_rev = await current()
await _run(lambda c: command.revision(config(c), message=message, autogenerate=True)) if current_rev and not _revision_on_disk(current_rev):
after = head() logger.warning(
return after if after != before else None "database revision %s is not in migrations/versions/; "
"new revision will follow local head %s (stamp unchanged)",
current_rev,
head(),
)
return await _run(lambda c: _write_revision(c, message))
async def run_manual_sql() -> None: async def run_manual_sql() -> None:
@ -487,7 +524,11 @@ def main(argv: Sequence[str] | None = None) -> None:
if args.command == "migrate": if args.command == "migrate":
await init_db() await init_db()
elif args.command in ("revision", "makemigrations"): elif args.command in ("revision", "makemigrations"):
try:
print(await autogenerate(args.message) or "no changes") print(await autogenerate(args.message) or "no changes")
except RuntimeError as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1) from None
elif args.command == "upgrade": elif args.command == "upgrade":
await upgrade(args.revision or "head") await upgrade(args.revision or "head")
elif args.command == "downgrade": elif args.command == "downgrade":

View File

@ -282,8 +282,9 @@ async def get_all_applications(
isread: bool = Query(default=True), isread: bool = Query(default=True),
assigned: bool | None = Query(default=None), assigned: bool | None = Query(default=None),
is_duplicate: bool | None = Query(default=None), is_duplicate: bool | None = Query(default=None),
no_suggestions: bool | None = Query(default=None),
search: str | None = Query(None), search: str | None = Query(None),
# Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged. # Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
top: int | None = Query(None, ge=1, le=500), top: int | None = Query(None, ge=1, le=500),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
@ -293,19 +294,19 @@ async def get_all_applications(
service=Email(session=session) service=Email(session=session)
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): 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):
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate) items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions)
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate) total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions)
return JSONResponse(content={"data":items,"total":total,"status_code":200}) return JSONResponse(content={"data":items,"total":total,"status_code":200})
if isread==False: if isread==False:
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate) items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions)
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate) total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions)
return JSONResponse(content={"data":items,"total":total,"status_code":200}) return JSONResponse(content={"data":items,"total":total,"status_code":200})
if record_id: if record_id:
item=await service.get_application_by_id(record_id) item=await service.get_application_by_id(record_id)
return JSONResponse(content={"data":item,"total":1,"status_code":200}) return JSONResponse(content={"data":item,"total":1,"status_code":200})
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate) items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate) total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
return JSONResponse(content={"data":items,"total":total,"status_code":200}) return JSONResponse(content={"data":items,"total":total,"status_code":200})
except HTTPException: except HTTPException:
raise raise

View File

@ -126,7 +126,7 @@ class Inbox(SQLModel, table=True):
if row["ats_result_id"] is not None: if row["ats_result_id"] is not None:
ats={ ats={
"id":str(row["ats_result_id"]), "id":str(row["ats_result_id"]),
"overall_score":row["overall_score"], "overall_score": float(row["overall_score"]) if row["overall_score"] is not None else None,
"band":row["band"] or None, "band":row["band"] or None,
"job_post_id":str(row["ats_job_post_id"]) if row["ats_job_post_id"] else None, "job_post_id":str(row["ats_job_post_id"]) if row["ats_job_post_id"] else None,
"computed_at":row["computed_at"].isoformat() if row["computed_at"] else None, "computed_at":row["computed_at"].isoformat() if row["computed_at"] else None,
@ -575,6 +575,7 @@ class Inbox_Messages(SQLModel, table=True):
email=address, email=address,
role_id=CANDIDATE_ROLE_ID, role_id=CANDIDATE_ROLE_ID,
password=hash_password(DEFAULT_CANDIDATE_PASSWORD), password=hash_password(DEFAULT_CANDIDATE_PASSWORD),
is_approved=True,
) )
session.add(user) session.add(user)
# autoflush=False: flush so users.id exists before inbox FK insert # autoflush=False: flush so users.id exists before inbox FK insert
@ -698,6 +699,7 @@ class Inbox_Messages(SQLModel, table=True):
application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED,
assigned: bool | None=None, assigned: bool | None=None,
is_duplicate: bool | None=None, is_duplicate: bool | None=None,
no_suggestions: bool | None=None,
): ):
"""The one WHERE chain shared by the list, the count and the bulk read UPDATE. """The one WHERE chain shared by the list, the count and the bulk read UPDATE.
@ -720,11 +722,18 @@ class Inbox_Messages(SQLModel, table=True):
statement = statement.where(cls.is_duplicate==True) # noqa: E712 statement = statement.where(cls.is_duplicate==True) # noqa: E712
elif is_duplicate is False: elif is_duplicate is False:
statement = statement.where(cls.is_duplicate==False) # noqa: E712 statement = statement.where(cls.is_duplicate==False) # noqa: E712
if no_suggestions is True:
statement = statement.where(cls.assigned_job_post_id.is_(None)).where(
or_(
cls.suggested_job_post_ids.is_(None),
func.jsonb_array_length(cls.suggested_job_post_ids) == 0,
)
)
return statement return statement
@classmethod @classmethod
async def get_inbox_messages( async def get_inbox_messages(
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None
): ):
# Page size is the caller's `top` (Inbox sends 10/25/50/100); `skip` is # Page size is the caller's `top` (Inbox sends 10/25/50/100); `skip` is
# (page-1)*top so page 1 of 25 -> 0..24, page 2 -> 25..49. Newest first # (page-1)*top so page 1 of 25 -> 0..24, page 2 -> 25..49. Newest first
@ -732,6 +741,7 @@ class Inbox_Messages(SQLModel, table=True):
statement = cls._apply_filters( statement = cls._apply_filters(
select(cls).order_by(cls.created_at.desc()), select(cls).order_by(cls.created_at.desc()),
search, isread, application_status, assigned, is_duplicate, search, isread, application_status, assigned, is_duplicate,
no_suggestions,
) )
if skip: if skip:
statement = statement.offset(skip) statement = statement.offset(skip)
@ -799,10 +809,11 @@ class Inbox_Messages(SQLModel, table=True):
return {str(job_id): int(n) for job_id, n in result.all()} return {str(job_id): int(n) for job_id, n in result.all()}
@classmethod @classmethod
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None): async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None):
statement = cls._apply_filters( statement = cls._apply_filters(
select(func.count()).select_from(cls), select(func.count()).select_from(cls),
search, isread, application_status, assigned, is_duplicate, search, isread, application_status, assigned, is_duplicate,
no_suggestions,
) )
result = await session.execute(statement) result = await session.execute(statement)
return result.scalar_one() return result.scalar_one()

View File

@ -273,13 +273,13 @@ class Email:
item["assigned_job_post"]=None item["assigned_job_post"]=None
return item return 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): 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):
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): 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):
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
elif isread==False: elif isread==False:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
else: else:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages]) 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)) for m in messages] return [serialize_application(m,linkedin_url=urls.get(m.id)) for m in messages]
@ -431,13 +431,13 @@ class Email:
results.append({"email":email,"sent":False}) results.append({"email":email,"sent":False})
return results return results
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None): async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None):
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED: if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate) return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
elif isread==False: elif isread==False:
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate) return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
else: else:
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate) return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions)
async def assign_job_post(self,record_id,job_post_id): async def assign_job_post(self,record_id,job_post_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)

View File

@ -281,14 +281,14 @@ async def cv_bank_upload(
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""Store a CV in the bank: the file plus its parsed text, nothing else. """Store a CV in the bank: parsed text + S3 object under Temp/{id}/.
No job, no user account, no inbox entry, no scoring the CV waits until a No job, no inbox entry, no scoring the CV waits until a recruiter picks
recruiter picks it up. Email/name are captured only if the CV contains it up. Email/name are captured only if the CV contains them. Insert the
them. The PDF bytes go INTO the database (cv_bank_files), never onto the row first so the S3 key can use the table PK; roll the row back if S3 fails."""
container filesystem, so production redeploys cannot lose a stored CV."""
from pathlib import PurePosixPath,PureWindowsPath from pathlib import PurePosixPath,PureWindowsPath
from job.candidate.models import Manual_UPLOAD_CANDIDATE from job.candidate.models import Manual_UPLOAD_CANDIDATE
from job.candidate.plugins import extract_candidate_email from job.candidate.plugins import extract_candidate_email
from s3.plugins import S3,S3ServiceError,S3Source
try: try:
content=await file.read() content=await file.read()
if len(content)>15*1024*1024: if len(content)>15*1024*1024:
@ -309,9 +309,28 @@ async def cv_bank_upload(
created_by=current_user.get("id"), created_by=current_user.get("id"),
pdf_bytes=content, pdf_bytes=content,
) )
try:
uploaded=S3().upload_for_record(
content,
original,
source=S3Source.TEMP,
record_id=row.id,
owner_id="",
content_type=file.content_type,
)
except S3ServiceError as e:
await Manual_UPLOAD_CANDIDATE.delete_bank_cv(session,row.id)
raise HTTPException(status_code=e.status_code,detail=e.message) from e
except Exception:
await Manual_UPLOAD_CANDIDATE.delete_bank_cv(session,row.id)
raise
row=await Manual_UPLOAD_CANDIDATE.set_file_path(
session,row.id,uploaded["url"],file_name=uploaded.get("filename") or original,
)
return JSONResponse(content={"data":{ return JSONResponse(content={"data":{
"id":str(row.id), "id":str(row.id),
"file_name":row.file_name, "file_name":row.file_name,
"file_path":row.file_path or None,
"candidate_email":row.candidate_email or None, "candidate_email":row.candidate_email or None,
"created_at":row.created_at.isoformat() if row.created_at else None, "created_at":row.created_at.isoformat() if row.created_at else None,
},"status_code":200}) },"status_code":200})
@ -336,6 +355,7 @@ async def cv_bank_fetch(
data=[{ data=[{
"id":str(r.id), "id":str(r.id),
"file_name":r.file_name, "file_name":r.file_name,
"file_path":(r.file_path or "").strip() or None,
"candidate_email":r.candidate_email or None, "candidate_email":r.candidate_email or None,
"candidate_name":r.candidate_name or None, "candidate_name":r.candidate_name or None,
"created_at":r.created_at.isoformat() if r.created_at else None, "created_at":r.created_at.isoformat() if r.created_at else None,
@ -384,11 +404,16 @@ async def cv_bank_delete(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
from job.candidate.models import Manual_UPLOAD_CANDIDATE from job.candidate.models import Manual_UPLOAD_CANDIDATE
from s3.plugins import S3,S3ServiceError
try: try:
row=await Manual_UPLOAD_CANDIDATE.delete_bank_cv(session,id) row=await Manual_UPLOAD_CANDIDATE.delete_bank_cv(session,id)
if not row: if not row:
raise HTTPException(status_code=404,detail="CV not found in the bank") raise HTTPException(status_code=404,detail="CV not found in the bank")
# Legacy rows from before bytes moved into the DB still carry a disk file. if (row.file_path or "").strip():
try:
S3().delete_object(row.file_path)
except S3ServiceError:
logger.warning("could not delete bank CV from S3 key=%s",row.file_path[:120])
FileRead.discard_upload(row.file_path) FileRead.discard_upload(row.file_path)
return JSONResponse(content={"data":{"id":str(row.id),"deleted":True},"status_code":200}) return JSONResponse(content={"data":{"id":str(row.id),"deleted":True},"status_code":200})
except HTTPException: except HTTPException:

View File

@ -122,7 +122,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
if row["ats_result_id"] is not None: if row["ats_result_id"] is not None:
ats={ ats={
"id":str(row["ats_result_id"]), "id":str(row["ats_result_id"]),
"overall_score":row["overall_score"], "overall_score": float(row["overall_score"]) if row["overall_score"] is not None else None,
"band":row["band"] or None, "band":row["band"] or None,
"job_post_id":str(row["ats_job_post_id"]) if row["ats_job_post_id"] else None, "job_post_id":str(row["ats_job_post_id"]) if row["ats_job_post_id"] else None,
"computed_at":row["computed_at"].isoformat() if row["computed_at"] else None, "computed_at":row["computed_at"].isoformat() if row["computed_at"] else None,
@ -211,6 +211,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
"role_id":8, "role_id":8,
"password":hash_password(default_pw), "password":hash_password(default_pw),
"is_active":True, "is_active":True,
"is_approved":True,
"is_deleted":False, "is_deleted":False,
"linkedin_url":linkedin_url, "linkedin_url":linkedin_url,
}) })
@ -257,10 +258,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
row=await cls.get_by_id(session,record_id) row=await cls.get_by_id(session,record_id)
if not row: if not row:
return None return None
row.file_path=(file_path or "").strip() url=(file_path or "").strip()
row.file_path=url
if file_name is not None: if file_name is not None:
row.file_name=(file_name or "").strip() row.file_name=(file_name or "").strip()
session.add(row) session.add(row)
if row.apply_via == "cv_bank":
file_row = await CvBankFiles.get(session, row.id)
if file_row:
file_row.file_path = url or None
session.add(file_row)
await session.commit() await session.commit()
await session.refresh(row) await session.refresh(row)
return row return row
@ -384,9 +391,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
candidate_name, full_text, file_name, candidate_name, full_text, file_name,
created_by, pdf_bytes, created_by, pdf_bytes,
content_type="application/pdf"): content_type="application/pdf"):
"""Bank a CV: metadata row + its bytes (cv_bank_files) in one commit — """Bank a CV: metadata row + its bytes (cv_bank_files) in one commit.
the PDF lives in the database, never on the container filesystem, so a file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload).
redeploy cannot lose a stored CV. file_path stays "" by design.
When the CV carries an email, the candidate ACCOUNT is created/reused When the CV carries an email, the candidate ACCOUNT is created/reused
(same pattern as create_manual_upload_candidate) so the person shows (same pattern as create_manual_upload_candidate) so the person shows
@ -411,6 +417,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
"role_id": role.id if role else 8, "role_id": role.id if role else 8,
"password": hash_password(os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")), "password": hash_password(os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")),
"is_active": True, "is_active": True,
"is_approved": True,
"is_deleted": False, "is_deleted": False,
}) })
elif user.is_deleted or not user.is_active: elif user.is_deleted or not user.is_active:
@ -439,6 +446,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
manual_upload_candidate_id=row.id, manual_upload_candidate_id=row.id,
content_type=content_type, content_type=content_type,
file_name=(file_name or "").strip(), file_name=(file_name or "").strip(),
file_path=None,
data=pdf_bytes, data=pdf_bytes,
)) ))
await session.commit() await session.commit()
@ -479,7 +487,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
class CvBankFiles(SQLModel, table=True): class CvBankFiles(SQLModel, table=True):
"""PDF bytes of a CV-bank entry — in the database so production redeploys """PDF bytes of a CV-bank entry — in the database so production redeploys
(ephemeral container filesystems) can never lose a stored CV. Created in (ephemeral container filesystems) can never lose a stored CV. Created in
prod by migrations/manual/010_cv_bank_files.sql.""" prod by migrations/manual/010_cv_bank_files.sql. file_path is the same
permanent S3 URL written to manual_upload_candidate.file_path."""
__tablename__ = "cv_bank_files" __tablename__ = "cv_bank_files"
@ -488,6 +497,7 @@ class CvBankFiles(SQLModel, table=True):
) )
content_type: str = Field(default="application/pdf") content_type: str = Field(default="application/pdf")
file_name: str | None = Field(default=None) file_name: str | None = Field(default=None)
file_path: str | None = Field(default=None)
data: bytes data: bytes
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))

View File

@ -0,0 +1,13 @@
-- 013_user_is_approved.sql
-- Self-signup accounts stay locked out of login until an admin sets
-- is_approved on Settings → Approvals. Existing active staff keep access.
-- Applied at startup by alembic_setup.run_manual_sql().
ALTER TABLE app.users
ADD COLUMN IF NOT EXISTS is_approved BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE app.users
SET is_approved = TRUE
WHERE is_active = TRUE
AND is_deleted = FALSE
AND is_approved = FALSE;

View File

@ -0,0 +1,15 @@
-- 014_cv_bank_files_file_path.sql
-- Store the permanent S3 object URL on the CV-bank file row itself, matching
-- manual_upload_candidate.file_path. Applied at startup by
-- alembic_setup.run_manual_sql().
ALTER TABLE app.cv_bank_files
ADD COLUMN IF NOT EXISTS file_path TEXT;
UPDATE app.cv_bank_files AS f
SET file_path = NULLIF(TRIM(m.file_path), '')
FROM app.manual_upload_candidate AS m
WHERE f.manual_upload_candidate_id = m.id
AND f.file_path IS NULL
AND m.file_path IS NOT NULL
AND TRIM(m.file_path) <> '';

View File

@ -30,5 +30,6 @@ def serialize_confirmation_result(user,*,already_confirmed: bool = False) -> dic
return { return {
"email": user.email, "email": user.email,
"is_active": user.is_active, "is_active": user.is_active,
"is_approved": user.is_approved,
"already_confirmed": already_confirmed, "already_confirmed": already_confirmed,
} }

View File

@ -82,7 +82,7 @@ async def open_s3_file(
@router.get("/s3/download") @router.get("/s3/download")
async def download_s3_file( async def download_s3_file(
key: str=Query(...,description="S3 key or stored file_path URL"), key: str=Query(...,description="S3 key or stored file_path URL"),
current_user: dict=Depends(require_permission(PermissionTag.CANDIDATES_VIEW,PermissionTag.SETTINGS_VIEW,require_all=False)), current_user: dict=Depends(require_permission(PermissionTag.CANDIDATES_VIEW,PermissionTag.SETTINGS_VIEW,PermissionTag.INBOX_VIEW,require_all=False)),
): ):
"""Stream a private PDF through the API (IAM GetObject — no public bucket).""" """Stream a private PDF through the API (IAM GetObject — no public bucket)."""
try: try:

View File

@ -13,6 +13,7 @@ CV keys are record-scoped (atomicity): DB row is created first, then upload uses
Email/{table_record_id}/{user_id}/{file_name}.pdf Email/{table_record_id}/{user_id}/{file_name}.pdf
Manual/{table_record_id}/{user_id}/{file_name}.pdf Manual/{table_record_id}/{user_id}/{file_name}.pdf
Form/{table_record_id}/{recruiter_id}/{file_name}.pdf Form/{table_record_id}/{recruiter_id}/{file_name}.pdf
Temp/{table_record_id}/{file_name}.pdf
Callers that create the row MUST delete it if upload_for_record fails. Callers that create the row MUST delete it if upload_for_record fails.
""" """
@ -55,7 +56,8 @@ class S3Source:
EMAIL="Email" EMAIL="Email"
MANUAL="Manual" MANUAL="Manual"
FORM="Form" FORM="Form"
ALL=frozenset({EMAIL,MANUAL,FORM}) TEMP="Temp"
ALL=frozenset({EMAIL,MANUAL,FORM,TEMP})
class S3ServiceError(Exception): class S3ServiceError(Exception):
@ -93,7 +95,7 @@ def assert_pdf(filename: str,content_type: str | None=None) -> str:
def normalize_source(source: str) -> str: def normalize_source(source: str) -> str:
raw=(source or "").strip() raw=(source or "").strip()
if not raw: if not raw:
raise S3ServiceError("source is required (Email|Manual|Form)",status_code=422) raise S3ServiceError("source is required (Email|Manual|Form|Temp)",status_code=422)
for name in S3Source.ALL: for name in S3Source.ALL:
if raw.lower()==name.lower(): if raw.lower()==name.lower():
return name return name
@ -152,15 +154,17 @@ class S3:
owner_id, owner_id,
filename: str, filename: str,
) -> str: ) -> str:
"""{Email|Manual|Form}/{table_record_id}/{user_or_recruiter_id}/{file}.pdf""" """{Email|Manual|Form}/{record_id}/{owner_id}/{file}.pdf or Temp/{record_id}/{file}.pdf"""
folder=normalize_source(source) folder=normalize_source(source)
rid=str(record_id or "").strip() rid=str(record_id or "").strip()
oid=str(owner_id or "").strip() oid=str(owner_id or "").strip()
if not rid: if not rid:
raise S3ServiceError("table_record_id is required before S3 upload",status_code=422) raise S3ServiceError("table_record_id is required before S3 upload",status_code=422)
safe=assert_pdf(filename)
if folder==S3Source.TEMP:
return f"{folder}/{rid}/{safe}"
if not oid: if not oid:
raise S3ServiceError("owner_id (user_id / recruiter_id) is required before S3 upload",status_code=422) raise S3ServiceError("owner_id (user_id / recruiter_id) is required before S3 upload",status_code=422)
safe=assert_pdf(filename)
return f"{folder}/{rid}/{oid}/{safe}" return f"{folder}/{rid}/{oid}/{safe}"
def object_url(self,key: str) -> str: def object_url(self,key: str) -> str:

View File

@ -121,6 +121,37 @@ async def create_user(
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.get("/users/pending-approvals")
async def fetch_pending_approvals(
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=User(session=session)
items=await service.get_pending_approvals()
return JSONResponse(content={"data":items,"total":len(items),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.put("/users/approve")
async def approve_user(
current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_EDIT)),
record_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
try:
service=User(session=session)
data=await service.approve_user(record_id)
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("/users/fetch") @router.get("/users/fetch")
async def fetch_users( async def fetch_users(
current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_VIEW)),

View File

@ -63,6 +63,7 @@ class Users(SQLModel, table=True):
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_active: bool = Field(default=False) is_active: bool = Field(default=False)
is_approved: bool = Field(default=False)
is_deleted: bool = Field(default=False) is_deleted: bool = Field(default=False)
@classmethod @classmethod
@ -187,6 +188,23 @@ class Users(SQLModel, table=True):
session.add(user) session.add(user)
return True return True
@classmethod
async def get_pending_approvals(cls, session: AsyncSession):
"""Email-confirmed staff accounts waiting on an admin to set is_approved."""
statement = (
select(cls)
.options(selectinload(cls.role))
.where(
cls.is_deleted == False, # noqa: E712
cls.is_active == True, # noqa: E712
cls.is_approved == False, # noqa: E712
cls.role_id != 8,
)
.order_by(cls.created_at.desc())
)
result = await session.execute(statement)
return result.scalars().all()
@classmethod @classmethod
async def insert_user(cls, session: AsyncSession, fields: dict): async def insert_user(cls, session: AsyncSession, fields: dict):
"""`fields["password"]` is expected to be hashed already — see users.plugins.""" """`fields["password"]` is expected to be hashed already — see users.plugins."""

View File

@ -254,6 +254,12 @@ async def get_current_user(
detail="User is inactive or does not exist", detail="User is inactive or does not exist",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if not user.is_approved:
raise HTTPException(
status_code=403,
detail="Your Approval is at Pending",
headers={"WWW-Authenticate": "Bearer"},
)
permissions = await Roles.resolve_tags(session, user.role) permissions = await Roles.resolve_tags(session, user.role)
return serialize_user(user, with_permissions=True, permissions=permissions) return serialize_user(user, with_permissions=True, permissions=permissions)

View File

@ -22,7 +22,7 @@ load_dotenv()
BCRYPT_MAX_BYTES = 72 BCRYPT_MAX_BYTES = 72
# Columns the server owns; a client must never be able to set them. # Columns the server owns; a client must never be able to set them.
SERVER_OWNED_FIELDS = ("id", "created_at", "updated_at", "is_deleted") SERVER_OWNED_FIELDS = ("id", "created_at", "updated_at", "is_deleted", "is_approved")
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY") JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")

View File

@ -23,6 +23,7 @@ def serialize_user(
"role_description": role.description if role is not None else None, "role_description": role.description if role is not None else None,
"linkedin_url": user.linkedin_url or None, "linkedin_url": user.linkedin_url or None,
"is_active": user.is_active, "is_active": user.is_active,
"is_approved": user.is_approved,
"is_deleted": user.is_deleted, "is_deleted": user.is_deleted,
"created_at": user.created_at.isoformat() if user.created_at else None, "created_at": user.created_at.isoformat() if user.created_at else None,
"updated_at": user.updated_at.isoformat() if user.updated_at else None, "updated_at": user.updated_at.isoformat() if user.updated_at else None,

View File

@ -44,6 +44,8 @@ class User:
fields=clean_user_payload(payload) fields=clean_user_payload(payload)
if not fields.get("password"): if not fields.get("password"):
raise HTTPException(status_code=400,detail="Password is required") raise HTTPException(status_code=400,detail="Password is required")
# Admin-created accounts skip the signup approval queue.
fields["is_approved"]=True
return await Users.insert_user(self.session,fields) return await Users.insert_user(self.session,fields)
async def signup_user(self,payload): async def signup_user(self,payload):
@ -54,6 +56,7 @@ class User:
if not fields.get("password"): if not fields.get("password"):
raise HTTPException(status_code=400,detail="Password is required") raise HTTPException(status_code=400,detail="Password is required")
fields["role_id"]=4 fields["role_id"]=4
fields["is_approved"]=False
user=await Users.insert_user(self.session,fields) user=await Users.insert_user(self.session,fields)
# Signup lands inactive; the mailed link is what flips is_active. # Signup lands inactive; the mailed link is what flips is_active.
service=Confirmation(session=self.session) service=Confirmation(session=self.session)
@ -148,8 +151,27 @@ class User:
raise HTTPException(status_code=401,detail="User is inactive") raise HTTPException(status_code=401,detail="User is inactive")
if not user.is_active: if not user.is_active:
raise HTTPException(status_code=401,detail="Please confirm your email address to activate your account") raise HTTPException(status_code=401,detail="Please confirm your email address to activate your account")
if not user.is_approved:
raise HTTPException(status_code=403,detail="Your Approval is at Pending")
return await Users.get_user_by_id(self.session,user.id) return await Users.get_user_by_id(self.session,user.id)
async def get_pending_approvals(self):
users=await Users.get_pending_approvals(self.session)
return [serialize_user(u) for u in users]
async def approve_user(self,record_id):
user=await Users.get_user_by_id(self.session,record_id)
if not user:
raise HTTPException(status_code=404,detail="User not found")
if user.is_deleted:
raise HTTPException(status_code=400,detail="User is inactive")
if not user.is_active:
raise HTTPException(status_code=400,detail="User must confirm their email before approval")
if user.is_approved:
return serialize_user(user)
updated=await Users.update_user(self.session,record_id,{"is_approved":True})
return serialize_user(updated)
async def refresh_access_token(self,refresh_token): async def refresh_access_token(self,refresh_token):
try: try:
payload=decode_token(refresh_token,expected_type="refresh") payload=decode_token(refresh_token,expected_type="refresh")
@ -158,4 +180,6 @@ class User:
user=await Users.get_user_by_id(self.session,payload.get("sub")) user=await Users.get_user_by_id(self.session,payload.get("sub"))
if not user or user.is_deleted or not user.is_active: if not user or user.is_deleted or not user.is_active:
raise HTTPException(status_code=401,detail="User is inactive or does not exist") raise HTTPException(status_code=401,detail="User is inactive or does not exist")
if not user.is_approved:
raise HTTPException(status_code=403,detail="Your Approval is at Pending")
return user return user

View File

@ -20,7 +20,7 @@ export function listMessages() {
* `assigned` is tri-valued: omit for no filter, true for rows with an * `assigned` is tri-valued: omit for no filter, true for rows with an
* assigned_job_post_id, false for the Job Matching queue. * assigned_job_post_id, false for the Job Matching queue.
*/ */
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate } = {}) { export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions } = {}) {
return request('/inbox/all-applications', { return request('/inbox/all-applications', {
// `isread` is tri-valued on the wire: omit it for every tab (server defaults // `isread` is tri-valued on the wire: omit it for every tab (server defaults
// to true = no filter), send false for the Unread tab only. buildUrl drops // to true = no filter), send false for the Unread tab only. buildUrl drops
@ -28,6 +28,8 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
// Same for `application_status`: omit for every tab (server defaults to // Same for `application_status`: omit for every tab (server defaults to
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only. // CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
// Same for `is_duplicate`: omit unless the Duplicates tab. // Same for `is_duplicate`: omit unless the Duplicates tab.
// `no_suggestions`: Job Matching "No suggestions" tab — unassigned + empty
// suggested_job_post_ids. Omit unless that tab.
params: { params: {
search, search,
top, top,
@ -37,6 +39,7 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
application_status: applicationStatus, application_status: applicationStatus,
assigned, assigned,
is_duplicate: isDuplicate, is_duplicate: isDuplicate,
no_suggestions: noSuggestions,
}, },
}) })
} }

View File

@ -143,49 +143,74 @@ export function toAtsScore(res) {
return data.inbox ?? data.manual ?? null return data.inbox ?? data.manual ?? null
} }
/** Card fields are rendered as React children — objects throw, not just look blank. */
function asText(value, fallback = null) {
if (value == null) return fallback
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed || fallback
}
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
return fallback
}
function asScore(value) {
if (value == null || value === '') return null
const n = typeof value === 'number' ? value : Number(value)
return Number.isFinite(n) ? n : null
}
function statusKey(value) {
if (typeof value === 'string') return value
if (value && typeof value === 'object' && typeof value.value === 'string') return value.value
return null
}
function sourceFields(row, kind) { function sourceFields(row, kind) {
if (kind === 'manual') { if (kind === 'manual') {
const id = row.id
return { return {
id: `manual:${row.id}`, id: id != null ? `manual:${id}` : `manual:${row.user_id ?? row.email ?? 'unknown'}`,
inboxId: null, inboxId: null,
manualUploadId: row.id, manualUploadId: id ?? null,
jobId: row.job_post_id ?? null, jobId: row.job_post_id ?? null,
jobTitle: row.title ?? null, jobTitle: asText(row.title),
currentTitle: row.current_position || null, currentTitle: asText(row.current_position),
currentCompany: row.current_company || null, currentCompany: asText(row.current_company),
source: row.platform || row.apply_via || 'Manual', source: asText(row.platform) || asText(row.apply_via) || 'Manual',
} }
} }
return { return {
id: row.inbox_id, id: row.inbox_id ?? `inbox:${row.user_id ?? row.email ?? 'unknown'}`,
inboxId: row.inbox_id, inboxId: row.inbox_id ?? null,
manualUploadId: null, manualUploadId: null,
jobId: row.assigned_job_post_id ?? null, jobId: row.assigned_job_post_id ?? null,
jobTitle: row.title ?? null, jobTitle: asText(row.title),
currentTitle: row.current_title || null, currentTitle: asText(row.current_title),
currentCompany: row.current_employment || null, currentCompany: asText(row.current_employment),
source: null, source: null,
} }
} }
/** /**
* Pipeline inbox or manual-upload row -> one kanban card. * Pipeline inbox or manual-upload row -> one kanban card, or null if the row
* * cannot be rendered. Inbox `id` is the inbox id, not the user id: the board
* Inbox `id` is the inbox id, not the user id: the board is one card per * is one card per APPLICATION. `userId` rides along for the profile deep link.
* APPLICATION. `userId` rides along for the deep link into the profile.
*/ */
export function toBoardCard(row, kind = 'inbox') { export function toBoardCard(row, kind = 'inbox') {
if (!row || typeof row !== 'object') return null
const src = sourceFields(row, kind) const src = sourceFields(row, kind)
const status = statusKey(row.application_status)
return { return {
...src, ...src,
userId: row.user_id ?? null, userId: row.user_id ?? null,
name: row.name || row.email || 'Unknown', name: asText(row.name) || asText(row.email) || 'Unknown',
email: row.email ?? null, email: asText(row.email),
stage: STAGE_FROM_STATUS[row.application_status] ?? 'Shortlist', stage: STAGE_FROM_STATUS[status] ?? 'Shortlist',
status: row.application_status ?? null, status,
experience: row.experience || null, experience: asText(row.experience),
aiScore: row.ats_result?.overall_score ?? null, aiScore: asScore(row.ats_result?.overall_score),
recommendation: row.ats_result?.band ?? null, recommendation: asText(row.ats_result?.band),
applied: row.created_at ? new Date(row.created_at) : null, applied: row.created_at ? new Date(row.created_at) : null,
} }
} }

View File

@ -17,14 +17,14 @@ export function firstKey(filePath) {
return (filePath || '').split(',')[0].trim() || null return (filePath || '').split(',')[0].trim() || null
} }
/** S3 object address (virtual-hosted URL) or record-scoped key Email|Manual|Form/... */ /** S3 object address (virtual-hosted URL) or record-scoped key Email|Manual|Form|Temp/... */
export function isS3Ref(value) { export function isS3Ref(value) {
const raw = firstKey(value) const raw = firstKey(value)
if (!raw) return false if (!raw) return false
if (/^https?:\/\//i.test(raw)) { if (/^https?:\/\//i.test(raw)) {
return /\.s3[.-]/i.test(raw) || /\/\/s3[.-]/i.test(raw) return /\.s3[.-]/i.test(raw) || /\/\/s3[.-]/i.test(raw)
} }
return /^(Email|Manual|Form)\//i.test(raw) return /^(Email|Manual|Form|Temp)\//i.test(raw)
} }
/** Presignable S3 ref, or an external http link (Drive / Sheet). Local disk paths are not. */ /** Presignable S3 ref, or an external http link (Drive / Sheet). Local disk paths are not. */
@ -50,7 +50,7 @@ export function resumeKeyFrom(item) {
* open as-is. Pass `tab` from a synchronous window.open to beat the pop-up blocker. * open as-is. Pass `tab` from a synchronous window.open to beat the pop-up blocker.
* *
* Leftover local paths (`app/inbox/decoded_attachments/...`) are not sent to * Leftover local paths (`app/inbox/decoded_attachments/...`) are not sent to
* S3 that produced NoSuchKey. Only Email|Manual|Form keys and S3 URLs presign. * S3 that produced NoSuchKey. Only Email|Manual|Form|Temp keys and S3 URLs presign.
*/ */
export async function openPdf(filePath, { tab } = {}) { export async function openPdf(filePath, { tab } = {}) {
const key = firstKey(filePath) const key = firstKey(filePath)

View File

@ -9,6 +9,14 @@ export function list({ record_id, search, top, skip, roleId } = {}) {
return request('/users/fetch', { params: { record_id, search, top, skip, role_id: roleId } }) return request('/users/fetch', { params: { record_id, search, top, skip, role_id: roleId } })
} }
export function listPendingApprovals() {
return request('/users/pending-approvals')
}
export function approve(recordId) {
return request('/users/approve', { method: 'PUT', params: { record_id: recordId } })
}
export function create(body) { export function create(body) {
return request('/users/create', { method: 'POST', body }) return request('/users/create', { method: 'POST', body })
} }

View File

@ -56,7 +56,10 @@ export const TODAY = new Date('2026-07-09T09:00:00');
const benefitsPool = ['Equity', '401(k) match', 'Unlimited PTO', 'Health & Dental', 'Remote stipend', 'Learning budget', 'Parental leave', 'Wellness program']; const benefitsPool = ['Equity', '401(k) match', 'Unlimited PTO', 'Health & Dental', 'Remote stipend', 'Learning budget', 'Parental leave', 'Wellness program'];
function fullName() { return pick(firstNames) + ' ' + pick(lastNames); } function fullName() { return pick(firstNames) + ' ' + pick(lastNames); }
function initials(name) { return name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase(); } function initials(name) {
const s = typeof name === 'string' ? name : String(name ?? '')
return s.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase()
}
function email(name) { return name.toLowerCase().replace(/[^a-z ]/g, '').replace(/ /g, '.') + '@' + pick(['gmail.com', 'outlook.com', 'proton.me', 'icloud.com']); } 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 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 daysAgo(n) { const d = new Date(TODAY); d.setDate(d.getDate() - n); return d; }
@ -64,7 +67,12 @@ export const TODAY = new Date('2026-07-09T09:00:00');
function fmtShort(d) { return d.toLocaleDateString('en-US', { month: 'short', day: '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)']; 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) { let s = 0; for (const c of name) s += c.charCodeAt(0); return avatarColors[s % avatarColors.length]; } function avatarColor(name) {
const s = typeof name === 'string' ? name : String(name ?? '')
let n = 0
for (const c of s) n += c.charCodeAt(0)
return avatarColors[n % avatarColors.length]
}
// ---------- Recruiters ---------- // ---------- Recruiters ----------
const recruiters = []; const recruiters = [];

View File

@ -59,6 +59,8 @@ export function friendlyAuthError(err, fallback = 'Something went wrong. Please
return fromServer || 'Please check your details and try again.' return fromServer || 'Please check your details and try again.'
case 401: case 401:
return fromServer || 'Incorrect email or password.' return fromServer || 'Incorrect email or password.'
case 403:
return fromServer || 'Your Approval is at Pending'
case 409: case 409:
return fromServer || 'An account with that email already exists.' return fromServer || 'An account with that email already exists.'
case 502: case 502:

View File

@ -9,6 +9,7 @@ export const qk = {
users: { users: {
all: () => ['users'], all: () => ['users'],
list: (p = {}) => ['users', 'list', p], list: (p = {}) => ['users', 'list', p],
pendingApprovals: () => ['users', 'pending-approvals'],
}, },
roles: { roles: {
all: () => ['roles'], all: () => ['roles'],

View File

@ -14,7 +14,7 @@ const titles = {
const subtitles = { const subtitles = {
verifying: 'Hold on while we activate your account.', verifying: 'Hold on while we activate your account.',
success: 'Your TalentFlow account is active.', success: 'Your email is confirmed. An admin still needs to approve your account.',
error: 'That link did not work. Request a new one below.', error: 'That link did not work. Request a new one below.',
} }
@ -44,11 +44,16 @@ export default function ConfirmEmail() {
const data = res?.data || {} const data = res?.data || {}
if (data.email) setEmail(data.email) if (data.email) setEmail(data.email)
setState('success') setState('success')
const needsApproval = data.is_approved === false
setAlert({ setAlert({
type: 'success', type: 'success',
message: data.already_confirmed message: needsApproval
? (data.already_confirmed
? 'Your email was already confirmed. Your Approval is at Pending.'
: 'Your email is confirmed. Your Approval is at Pending.')
: (data.already_confirmed
? 'Your email was already confirmed. You can sign in.' ? 'Your email was already confirmed. You can sign in.'
: 'Your email is confirmed. You can sign in now.', : 'Your email is confirmed. You can sign in now.'),
}) })
} catch (err) { } catch (err) {
setState('error') setState('error')

View File

@ -92,7 +92,7 @@ export default function Signup() {
return ( return (
<AuthLayout <AuthLayout
title="Check your email" title="Check your email"
subtitle="Open the link we sent to activate your account." subtitle="Open the link we sent. After that, an admin must approve your account before you can sign in."
foot={ foot={
<> <>
Already confirmed?{' '} Already confirmed?{' '}

View File

@ -7,16 +7,15 @@
re-uploading the same bytes updates the existing record. re-uploading the same bytes updates the existing record.
No-Job mode ("store in CV bank"): each file goes to POST No-Job mode ("store in CV bank"): each file goes to POST
/candidate/cv-bank/upload individually parsed and stored as a private /candidate/cv-bank/upload individually parsed, stored as a bank row, and
bank row: no job, no user account, no inbox entry, no scoring. The bank uploaded to S3 under Temp/{id}/. file_path is the permanent object URL.
is listed right below the dropzone and is where stored CVs are browsed,
downloaded and (later) picked up for a job.
============================================================ */ ============================================================ */
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import OpenResumeButton from '../ui/OpenResumeButton'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
@ -24,6 +23,7 @@ import JobCandidates from './JobCandidates'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates' import * as candidatesApi from '../api/candidates'
import * as s3Api from '../api/s3'
/* Sentinel for the job picker: store CVs without scoring or assignment. */ /* Sentinel for the job picker: store CVs without scoring or assignment. */
const NO_JOB = '__none__' const NO_JOB = '__none__'
@ -363,7 +363,12 @@ function CvBank() {
const rows = Array.isArray(bankQuery.data?.data) ? bankQuery.data.data : [] const rows = Array.isArray(bankQuery.data?.data) ? bankQuery.data.data : []
async function view(row) { async function view(row) {
const tab = s3Api.canOpen(row.file_path) ? window.open('about:blank', '_blank') : null
try { try {
if (s3Api.canOpen(row.file_path)) {
await s3Api.openPdf(row.file_path, { tab })
return
}
const url = await candidatesApi.viewCvBankCv(row.id) const url = await candidatesApi.viewCvBankCv(row.id)
if (!url) { if (!url) {
toast('The CV file could not be found', 'error') toast('The CV file could not be found', 'error')
@ -371,6 +376,7 @@ function CvBank() {
} }
setPreview({ name: row.file_name || 'CV', url }) setPreview({ name: row.file_name || 'CV', url })
} catch (err) { } catch (err) {
if (tab && !tab.closed) tab.close()
toast(friendlyAuthError(err, 'Could not open the CV'), 'error') toast(friendlyAuthError(err, 'Could not open the CV'), 'error')
} }
} }
@ -426,8 +432,16 @@ function CvBank() {
{r.candidate_email || 'No email detected'} {r.candidate_email || 'No email detected'}
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''} {r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
</div> </div>
{r.file_path ? (
<div className="cell-sub truncate" title={r.file_path} style={{ marginTop: 2 }}>
{r.file_path}
</div>
) : (
<div className="cell-sub" style={{ marginTop: 2 }}>No S3 path yet</div>
)}
</div> </div>
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}> <div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
<OpenResumeButton filePath={r.file_path} className="btn btn-secondary btn-sm" />
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}> <button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
<Icon name="eye" /> <Icon name="eye" />
</button> </button>

View File

@ -1,8 +1,9 @@
/* ============================================================ /* ============================================================
Job Matching assign each inbound application to exactly one job post. Job Matching assign each inbound application to exactly one job post.
Queue layout mirrors Inbox (Tabs over a .split). The AI's suggested_job_post_ids Queue layout mirrors Inbox (Tabs over a .split). Page size defaults to 10;
land here as a radiogroup; Assign writes assigned_job_post_id. Nothing is skip = (page-1)*limit, same as Inbox. The AI's suggested_job_post_ids land
here as a radiogroup; Assign writes assigned_job_post_id. Nothing is
marked read that stays Inbox's job so this page cannot silently move the marked read that stays Inbox's job so this page cannot silently move the
inbox nav badge. inbox nav badge.
============================================================ */ ============================================================ */
@ -15,6 +16,7 @@ import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import OpenResumeButton from '../ui/OpenResumeButton' import OpenResumeButton from '../ui/OpenResumeButton'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles' import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
@ -38,10 +40,13 @@ const TABS = [
const TAB_FILTERS = { const TAB_FILTERS = {
needs: { assigned: false }, needs: { assigned: false },
assigned: { assigned: true }, assigned: { assigned: true },
none: {}, none: { assigned: false, noSuggestions: true },
all: {}, all: {},
} }
/** Same cap as GET /inbox/all-applications `top`. */
const PAGE_SIZE_MAX = 500
const RESUME_STATUS = { const RESUME_STATUS = {
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed', processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
failed: 'Failed', dlq: 'Failed', skipped: 'Pending', failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
@ -175,6 +180,8 @@ export default function Matching() {
const deepLink = searchParams.get('record') const deepLink = searchParams.get('record')
const [tab, setTab] = useState('needs') const [tab, setTab] = useState('needs')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [selectedId, setSelectedId] = useState(deepLink || null) const [selectedId, setSelectedId] = useState(deepLink || null)
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [selection, setSelection] = useState(null) const [selection, setSelection] = useState(null)
@ -183,10 +190,16 @@ export default function Matching() {
const [whyOpen, setWhyOpen] = useState(false) const [whyOpen, setWhyOpen] = useState(false)
const tabFilter = TAB_FILTERS[tab] ?? {} const tabFilter = TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({
...tabFilter,
top: pageSize,
skip: (page - 1) * pageSize,
...(q.trim() ? { search: q.trim() } : {}),
}), [tabFilter, page, pageSize, q])
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: qk.mailbox.assignments({ ...tabFilter, tab }), queryKey: qk.mailbox.assignments({ ...listParams, tab }),
queryFn: () => fetchApplications(tabFilter), queryFn: () => fetchApplications(listParams),
}) })
const needsCount = useQuery({ const needsCount = useQuery({
@ -203,29 +216,21 @@ export default function Matching() {
}) })
const noneCountQuery = useQuery({ const noneCountQuery = useQuery({
queryKey: qk.mailbox.assignments({ kind: 'none' }), queryKey: qk.mailbox.assignments({ kind: 'none' }),
queryFn: async () => { queryFn: async () => (await inboxApi.listApplications({ assigned: false, noSuggestions: true, top: 1 }))?.total ?? 0,
const res = await fetchApplications({})
return res.rows.filter((r) => !r.assignedId && r.suggestedIds.length === 0).length
},
}) })
const rows = listQuery.data?.rows ?? [] const rows = listQuery.data?.rows ?? []
const filtered = useMemo(() => { const filtered = rows
let list = rows const total = listQuery.data?.total ?? 0
if (tab === 'none') { const pages = Math.max(1, Math.ceil(total / pageSize))
list = list.filter((r) => !r.assignedId && r.suggestedIds.length === 0) const currentPage = Math.min(page, pages)
}
const needle = q.trim().toLowerCase()
if (!needle) return list
return list.filter((r) => (
r.name.toLowerCase().includes(needle)
|| r.position.toLowerCase().includes(needle)
|| r.email.toLowerCase().includes(needle)
))
}, [rows, tab, q])
const noneCount = noneCountQuery.data ?? 0 const noneCount = noneCountQuery.data ?? 0
useEffect(() => {
if (listQuery.isSuccess && page > pages) setPage(pages)
}, [listQuery.isSuccess, page, pages])
// Preselect deep link once, then clear the query so refresh doesn't re-pin. // Preselect deep link once, then clear the query so refresh doesn't re-pin.
useEffect(() => { useEffect(() => {
if (!deepLink) return undefined if (!deepLink) return undefined
@ -395,7 +400,7 @@ export default function Matching() {
<div style={{ padding: '0 8px', borderBottom: '1px solid var(--border)' }}> <div style={{ padding: '0 8px', borderBottom: '1px solid var(--border)' }}>
<Tabs <Tabs
value={tab} value={tab}
onChange={(t) => { setTab(t); setSelectedId(null) }} onChange={(t) => { setTab(t); setPage(1); setSelectedId(null) }}
className="tabs tabs-wrap" className="tabs tabs-wrap"
tabs={TABS.map((t) => ({ tabs={TABS.map((t) => ({
key: t.key, key: t.key,
@ -406,11 +411,15 @@ export default function Matching() {
</div> </div>
<div className="split"> <div className="split">
<div className="split-list"> <div className="split-list inbox-queue">
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}> <div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
<div className="toolbar-search" style={{ maxWidth: 'none' }}> <div className="toolbar-search" style={{ maxWidth: 'none' }}>
<Icon name="search" /> <Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search…" /> <input
value={q}
onChange={(e) => { setQ(e.target.value); setPage(1) }}
placeholder="Search…"
/>
</div> </div>
</div> </div>
<div> <div>
@ -447,6 +456,24 @@ export default function Matching() {
</div> </div>
))} ))}
</div> </div>
{listQuery.isSuccess && total > 0 && (
<Pagination
from={total ? (currentPage - 1) * pageSize + 1 : 0}
to={Math.min(currentPage * pageSize, total)}
total={total}
page={currentPage}
pages={pages}
setPage={(p) => { setPage(p); setSelectedId(null) }}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
pageSizeMax={PAGE_SIZE_MAX}
onPageSizeChange={(n) => {
setPageSize(n)
setPage((p) => pageAfterSizeChange(p, total, n))
setSelectedId(null)
}}
/>
)}
</div> </div>
<div className="split-detail"> <div className="split-detail">

View File

@ -56,14 +56,28 @@ function byScoreDesc(a, b) {
return b.aiScore - a.aiScore || (b.applied ?? 0) - (a.applied ?? 0) return b.aiScore - a.aiScore || (b.applied ?? 0) - (a.applied ?? 0)
} }
function mapCards(rows, mapper) {
const cards = []
for (const row of rows) {
try {
const card = mapper(row)
if (card) cards.push(card)
} catch {
// A poisoned row must not fail the query (that is the "Could not load"
// empty state) or reach the card renderer (ErrorBoundary).
}
}
return cards
}
async function fetchBoard(jobId) { async function fetchBoard(jobId) {
const res = await pipelineApi.listApplications({ jobId, limit: BOARD_LIMIT }) const res = await pipelineApi.listApplications({ jobId, limit: BOARD_LIMIT })
const inbox = Array.isArray(res?.data?.inbox) ? res.data.inbox : [] const inbox = Array.isArray(res?.data?.inbox) ? res.data.inbox : []
const manuals = Array.isArray(res?.data?.manual_upload) ? res.data.manual_upload : [] const manuals = Array.isArray(res?.data?.manual_upload) ? res.data.manual_upload : []
return { return {
cards: [ cards: [
...inbox.map((row) => pipelineApi.toBoardCard(row)), ...mapCards(inbox, (row) => pipelineApi.toBoardCard(row)),
...manuals.map((row) => pipelineApi.toManualBoardCard(row)), ...mapCards(manuals, (row) => pipelineApi.toManualBoardCard(row)),
].sort(byScoreDesc), ].sort(byScoreDesc),
total: res?.total ?? 0, total: res?.total ?? 0,
stageCounts: pipelineApi.toStageCounts(res?.counts?.by_status), stageCounts: pipelineApi.toStageCounts(res?.counts?.by_status),
@ -73,7 +87,12 @@ async function fetchBoard(jobId) {
async function fetchJobs() { async function fetchJobs() {
const res = await jobPostsApi.list({ activeOnly: true, top: JOB_LIMIT }) const res = await jobPostsApi.list({ activeOnly: true, top: JOB_LIMIT })
const rows = Array.isArray(res?.data) ? res.data : [] 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)
.map((row) => ({
id: row.id,
title: typeof row.title === 'string' ? row.title : String(row.title ?? ''),
}))
} }
/** /**
@ -104,19 +123,27 @@ export default function Pipeline() {
queryFn: () => fetchBoard(jobId), queryFn: () => fetchBoard(jobId),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}) })
const { data: jobs = [] } = useQuery({ queryKey: qk.jobPosts.list({ top: JOB_LIMIT }), queryFn: fetchJobs }) const jobsQuery = useQuery({
queryKey: qk.jobPosts.list({ top: JOB_LIMIT, scope: 'pipeline' }),
queryFn: fetchJobs,
})
const jobs = Array.isArray(jobsQuery.data) ? jobsQuery.data : []
/* The route is behind pipeline.view, but the WRITE needs pipeline.edit a /* The route is behind pipeline.view, but the WRITE needs pipeline.edit a
viewer gets a read-only board instead of drags that 403 on drop. */ viewer gets a read-only board instead of drags that 403 on drop. */
const canEdit = can('pipeline.edit') const canEdit = can('pipeline.edit')
const candidates = board.data?.cards ?? [] const candidates = Array.isArray(board.data?.cards) ? board.data.cards.filter(Boolean) : []
const total = board.data?.total ?? 0 const total = board.data?.total ?? 0
const stageCounts = board.data?.stageCounts ?? {} const stageCounts = board.data?.stageCounts && typeof board.data.stageCounts === 'object'
? board.data.stageCounts
: {}
const byStage = useMemo(() => { const byStage = useMemo(() => {
const map = Object.fromEntries(KANBAN_STAGES.map((s) => [s.name, []])) const map = Object.fromEntries(KANBAN_STAGES.map((s) => [s.name, []]))
for (const c of candidates) if (map[c.stage]) map[c.stage].push(c) for (const c of candidates) {
if (c && map[c.stage]) map[c.stage].push(c)
}
return map return map
}, [candidates]) }, [candidates])
@ -134,9 +161,9 @@ export default function Pipeline() {
await qc.cancelQueries({ queryKey: boardKey }) await qc.cancelQueries({ queryKey: boardKey })
const previous = qc.getQueryData(boardKey) const previous = qc.getQueryData(boardKey)
qc.setQueryData(boardKey, (old) => { qc.setQueryData(boardKey, (old) => {
if (!old) return old if (!old || !Array.isArray(old.cards)) return old
const from = card.stage const from = card.stage
const nextCounts = { ...old.stageCounts } const nextCounts = { ...(old.stageCounts || {}) }
if (from && from !== stage) { if (from && from !== stage) {
nextCounts[from] = Math.max(0, (nextCounts[from] ?? 0) - 1) nextCounts[from] = Math.max(0, (nextCounts[from] ?? 0) - 1)
nextCounts[stage] = (nextCounts[stage] ?? 0) + 1 nextCounts[stage] = (nextCounts[stage] ?? 0) + 1
@ -191,7 +218,7 @@ export default function Pipeline() {
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}> <select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option> <option value="">All Jobs</option>
{jobs.map((j) => ( {jobs.map((j) => (
<option key={j.id} value={j.id}>{j.title}</option> <option key={j.id} value={j.id}>{j.title || 'Untitled'}</option>
))} ))}
</select> </select>
<button <button

View File

@ -32,7 +32,7 @@ function humaniseSlug(slug) {
} }
const TABS = [ const TABS = [
'General', 'Users', 'Permissions', 'Notifications', 'General', 'Users', 'Approvals', 'Permissions', 'Notifications',
'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance', 'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance',
] ]
@ -114,6 +114,12 @@ export default function Settings() {
const canConfigure = can('settings.configure') const canConfigure = can('settings.configure')
const showOrgSave = ORG_TABS.has(tab) const showOrgSave = ORG_TABS.has(tab)
const pendingQuery = useQuery({
queryKey: qk.users.pendingApprovals(),
queryFn: () => usersApi.listPendingApprovals().then((r) => r.data ?? []),
})
const pendingCount = pendingQuery.data?.length ?? 0
return ( return (
<div className="page"> <div className="page">
<PageHeader <PageHeader
@ -130,11 +136,20 @@ export default function Settings() {
)} )}
/> />
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} /> <Tabs
value={tab}
onChange={setTab}
tabs={TABS.map((t) => ({
key: t,
label: t,
count: t === 'Approvals' && pendingCount ? pendingCount : undefined,
}))}
/>
<div className="tab-pane active"> <div className="tab-pane active">
{tab === 'General' && <General registerSave={(fn) => { saveRef.current = fn }} />} {tab === 'General' && <General registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Users' && <Users />} {tab === 'Users' && <Users />}
{tab === 'Approvals' && <Approvals />}
{tab === 'Permissions' && <Permissions />} {tab === 'Permissions' && <Permissions />}
{tab === 'Notifications' && <Notifications registerSave={(fn) => { saveRef.current = fn }} />} {tab === 'Notifications' && <Notifications registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Email Templates' && <EmailTemplates />} {tab === 'Email Templates' && <EmailTemplates />}
@ -308,7 +323,11 @@ function Users() {
</div> </div>
</td> </td>
<td><Badge className="b-indigo">{u.role_name || 'No role'}</Badge></td> <td><Badge className="b-indigo">{u.role_name || 'No role'}</Badge></td>
<td><Badge>{u.is_active ? 'Active' : 'Pending'}</Badge></td> <td>
<Badge className={!u.is_active ? undefined : u.is_approved ? 'b-green' : 'b-amber'}>
{!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 ? String(u.created_at).slice(0, 10) : '—'}</td>
<td style={{ textAlign: 'right' }}> <td style={{ textAlign: 'right' }}>
<div className="row-actions"> <div className="row-actions">
@ -335,6 +354,104 @@ function Users() {
) )
} }
function Approvals() {
const { toast } = useToast()
const { can } = usePermission()
const qc = useQueryClient()
const pendingQuery = useQuery({
queryKey: qk.users.pendingApprovals(),
queryFn: () => usersApi.listPendingApprovals().then((r) => r.data ?? []),
})
const pending = pendingQuery.data ?? []
const canApprove = can('rbac_users.edit')
const approve = useMutation({
mutationFn: (id) => usersApi.approve(id),
onSuccess: (_data, id) => {
qc.invalidateQueries({ queryKey: qk.users.all() })
const name = pending.find((u) => String(u.id) === String(id))?.name
toast(name ? `${name} has been approved` : 'User approved', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not approve this user.'), 'error'),
})
return (
<div className="card">
<div className="card-head">
<div>
<h3>Pending Approvals</h3>
<span className="ch-sub">
{pendingQuery.isPending
? 'Loading…'
: `${pending.length} waiting for approval`}
</span>
</div>
</div>
{pendingQuery.isError ? (
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load approvals">
{friendlyAuthError(pendingQuery.error, 'The server did not return pending users.')}
</EmptyState>
</div>
) : !pendingQuery.isPending && pending.length === 0 ? (
<div className="card-body">
<EmptyState icon="check" title="No pending approvals">
New signups appear here after they confirm their email.
</EmptyState>
</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Signed up</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{pending.map((u) => (
<tr key={u.id}>
<td>
<div className="user-cell">
<Avatar name={u.name} />
<div className="min-w-0">
<div className="cell-primary">{u.name}</div>
<div className="cell-sub" title={u.email || undefined}>{u.email}</div>
</div>
</div>
</td>
<td><Badge className="b-indigo">{u.role_name || 'No role'}</Badge></td>
<td className="text-muted">{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-primary btn-sm"
disabled={!canApprove || approve.isPending}
onClick={() => approve.mutate(u.id)}
>
<Icon name="check" /> {approve.isPending && approve.variables === u.id ? 'Approving…' : 'Approve'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!canApprove && pending.length > 0 && (
<div className="card-body">
<div className="alert alert-danger">
Approving a user requires <code>rbac_users.edit</code>. You can see the queue but cannot approve.
</div>
</div>
)}
</div>
)
}
function AssignRoleModal({ user, users, onClose }) { function AssignRoleModal({ user, users, onClose }) {
const { toast } = useToast() const { toast } = useToast()
const { can } = usePermission() const { can } = usePermission()

View File

@ -10,8 +10,9 @@ import Chart from './Chart'
import { avatarColor, initials as initialsOf } from '../data/seed' import { avatarColor, initials as initialsOf } from '../data/seed'
export function Avatar({ name = '', initials, color, className = '' }) { export function Avatar({ name = '', initials, color, className = '' }) {
const bg = color || avatarColor(name || '') const label = typeof name === 'string' ? name : String(name ?? '')
const text = initials || initialsOf(name || '?') const bg = color || avatarColor(label)
const text = (typeof initials === 'string' && initials) || initialsOf(label || '?')
return ( return (
<span className={`avatar ${className}`} style={{ background: bg }}> <span className={`avatar ${className}`} style={{ background: bg }}>
{text} {text}
@ -53,15 +54,19 @@ export function Badge({ children, className }) {
} }
export function ScoreChip({ score }) { export function ScoreChip({ score }) {
const n = Number(score)
if (!Number.isFinite(n)) return null
const pct = Math.min(100, Math.max(0, n))
const shown = Math.round(n)
// Theme tokens, not fixed hex the old greens/blues dropped to ~2.6:1 on dark cards. // Theme tokens, not fixed hex the old greens/blues dropped to ~2.6:1 on dark cards.
const color = const color =
score >= 85 ? 'var(--success)' pct >= 85 ? 'var(--success)'
: score >= 70 ? 'var(--warning)' : pct >= 70 ? 'var(--warning)'
: score >= 55 ? 'var(--info)' : 'var(--danger)' : pct >= 55 ? 'var(--info)' : 'var(--danger)'
return ( return (
<span className="score"> <span className="score">
<span className="score-ring" style={{ '--pct': score, '--sc-color': color }}> <span className="score-ring" style={{ '--pct': pct, '--sc-color': color }}>
<span style={{ color }}>{score}</span> <span style={{ color }}>{shown}</span>
</span> </span>
</span> </span>
) )