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 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.operations import Operations
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
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.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:
"""Keep autogenerate inside the schemas this application owns."""
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":
return True
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)."""
try:
ScriptDirectory.from_config(config()).get_revision(revision_id)
except ResolutionError:
except (ResolutionError, CommandError):
# ScriptDirectory.get_revision wraps ResolutionError in CommandError.
return False
return True
@ -365,6 +373,30 @@ async def apply_model_drift() -> bool:
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:
"""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")
return None
logger.info("%s schema difference(s) detected", len(diffs))
before = head()
await _run(lambda c: command.revision(config(c), message=message, autogenerate=True))
after = head()
return after if after != before else None
current_rev = await current()
if current_rev and not _revision_on_disk(current_rev):
logger.warning(
"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:
@ -487,7 +524,11 @@ def main(argv: Sequence[str] | None = None) -> None:
if args.command == "migrate":
await init_db()
elif args.command in ("revision", "makemigrations"):
try:
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":
await upgrade(args.revision or "head")
elif args.command == "downgrade":

View File

@ -282,8 +282,9 @@ async def get_all_applications(
isread: bool = Query(default=True),
assigned: bool | None = Query(default=None),
is_duplicate: bool | None = Query(default=None),
no_suggestions: bool | None = Query(default=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),
skip: int = Query(0, ge=0),
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
@ -293,19 +294,19 @@ async def get_all_applications(
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):
items=await service.get_all_applications(top, skip, 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)
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, no_suggestions=no_suggestions)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
if isread==False:
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate)
total=await service.count_inbox_messages(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, no_suggestions=no_suggestions)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
if record_id:
item=await service.get_application_by_id(record_id)
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)
total=await service.count_inbox_messages(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,no_suggestions=no_suggestions)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
except HTTPException:
raise

View File

@ -126,7 +126,7 @@ class Inbox(SQLModel, table=True):
if row["ats_result_id"] is not None:
ats={
"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,
"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,
@ -575,6 +575,7 @@ class Inbox_Messages(SQLModel, table=True):
email=address,
role_id=CANDIDATE_ROLE_ID,
password=hash_password(DEFAULT_CANDIDATE_PASSWORD),
is_approved=True,
)
session.add(user)
# 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,
assigned: 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.
@ -720,11 +722,18 @@ class Inbox_Messages(SQLModel, table=True):
statement = statement.where(cls.is_duplicate==True) # noqa: E712
elif is_duplicate is False:
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
@classmethod
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-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(
select(cls).order_by(cls.created_at.desc()),
search, isread, application_status, assigned, is_duplicate,
no_suggestions,
)
if 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()}
@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(
select(func.count()).select_from(cls),
search, isread, application_status, assigned, is_duplicate,
no_suggestions,
)
result = await session.execute(statement)
return result.scalar_one()

View File

@ -273,13 +273,13 @@ class Email:
item["assigned_job_post"]=None
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):
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:
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:
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])
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})
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:
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:
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:
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):
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)),
session: AsyncSession = Depends(get_session),
):
"""Store a CV in the bank: the file plus its parsed text, nothing else.
No job, no user account, no inbox entry, no scoring the CV waits until a
recruiter picks it up. Email/name are captured only if the CV contains
them. The PDF bytes go INTO the database (cv_bank_files), never onto the
container filesystem, so production redeploys cannot lose a stored CV."""
"""Store a CV in the bank: parsed text + S3 object under Temp/{id}/.
No job, no inbox entry, no scoring the CV waits until a recruiter picks
it up. Email/name are captured only if the CV contains them. Insert the
row first so the S3 key can use the table PK; roll the row back if S3 fails."""
from pathlib import PurePosixPath,PureWindowsPath
from job.candidate.models import Manual_UPLOAD_CANDIDATE
from job.candidate.plugins import extract_candidate_email
from s3.plugins import S3,S3ServiceError,S3Source
try:
content=await file.read()
if len(content)>15*1024*1024:
@ -309,9 +309,28 @@ async def cv_bank_upload(
created_by=current_user.get("id"),
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":{
"id":str(row.id),
"file_name":row.file_name,
"file_path":row.file_path or None,
"candidate_email":row.candidate_email or None,
"created_at":row.created_at.isoformat() if row.created_at else None,
},"status_code":200})
@ -336,6 +355,7 @@ async def cv_bank_fetch(
data=[{
"id":str(r.id),
"file_name":r.file_name,
"file_path":(r.file_path or "").strip() or None,
"candidate_email":r.candidate_email or None,
"candidate_name":r.candidate_name or 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),
):
from job.candidate.models import Manual_UPLOAD_CANDIDATE
from s3.plugins import S3,S3ServiceError
try:
row=await Manual_UPLOAD_CANDIDATE.delete_bank_cv(session,id)
if not row:
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)
return JSONResponse(content={"data":{"id":str(row.id),"deleted":True},"status_code":200})
except HTTPException:

View File

@ -122,7 +122,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
if row["ats_result_id"] is not None:
ats={
"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,
"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,
@ -211,6 +211,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
"role_id":8,
"password":hash_password(default_pw),
"is_active":True,
"is_approved":True,
"is_deleted":False,
"linkedin_url":linkedin_url,
})
@ -257,10 +258,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
row=await cls.get_by_id(session,record_id)
if not row:
return None
row.file_path=(file_path or "").strip()
url=(file_path or "").strip()
row.file_path=url
if file_name is not None:
row.file_name=(file_name or "").strip()
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.refresh(row)
return row
@ -384,9 +391,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
candidate_name, full_text, file_name,
created_by, pdf_bytes,
content_type="application/pdf"):
"""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
redeploy cannot lose a stored CV. file_path stays "" by design.
"""Bank a CV: metadata row + its bytes (cv_bank_files) in one commit.
file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload).
When the CV carries an email, the candidate ACCOUNT is created/reused
(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,
"password": hash_password(os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")),
"is_active": True,
"is_approved": True,
"is_deleted": False,
})
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,
content_type=content_type,
file_name=(file_name or "").strip(),
file_path=None,
data=pdf_bytes,
))
await session.commit()
@ -479,7 +487,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
class CvBankFiles(SQLModel, table=True):
"""PDF bytes of a CV-bank entry — in the database so production redeploys
(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"
@ -488,6 +497,7 @@ class CvBankFiles(SQLModel, table=True):
)
content_type: str = Field(default="application/pdf")
file_name: str | None = Field(default=None)
file_path: str | None = Field(default=None)
data: bytes
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 {
"email": user.email,
"is_active": user.is_active,
"is_approved": user.is_approved,
"already_confirmed": already_confirmed,
}

View File

@ -82,7 +82,7 @@ async def open_s3_file(
@router.get("/s3/download")
async def download_s3_file(
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)."""
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
Manual/{table_record_id}/{user_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.
"""
@ -55,7 +56,8 @@ class S3Source:
EMAIL="Email"
MANUAL="Manual"
FORM="Form"
ALL=frozenset({EMAIL,MANUAL,FORM})
TEMP="Temp"
ALL=frozenset({EMAIL,MANUAL,FORM,TEMP})
class S3ServiceError(Exception):
@ -93,7 +95,7 @@ def assert_pdf(filename: str,content_type: str | None=None) -> str:
def normalize_source(source: str) -> str:
raw=(source or "").strip()
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:
if raw.lower()==name.lower():
return name
@ -152,15 +154,17 @@ class S3:
owner_id,
filename: 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)
rid=str(record_id or "").strip()
oid=str(owner_id or "").strip()
if not rid:
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:
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}"
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))
@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")
async def fetch_users(
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))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_active: bool = Field(default=False)
is_approved: bool = Field(default=False)
is_deleted: bool = Field(default=False)
@classmethod
@ -187,6 +188,23 @@ class Users(SQLModel, table=True):
session.add(user)
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
async def insert_user(cls, session: AsyncSession, fields: dict):
"""`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",
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)
return serialize_user(user, with_permissions=True, permissions=permissions)

View File

@ -22,7 +22,7 @@ load_dotenv()
BCRYPT_MAX_BYTES = 72
# 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_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,
"linkedin_url": user.linkedin_url or None,
"is_active": user.is_active,
"is_approved": user.is_approved,
"is_deleted": user.is_deleted,
"created_at": user.created_at.isoformat() if user.created_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)
if not fields.get("password"):
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)
async def signup_user(self,payload):
@ -54,6 +56,7 @@ class User:
if not fields.get("password"):
raise HTTPException(status_code=400,detail="Password is required")
fields["role_id"]=4
fields["is_approved"]=False
user=await Users.insert_user(self.session,fields)
# Signup lands inactive; the mailed link is what flips is_active.
service=Confirmation(session=self.session)
@ -148,8 +151,27 @@ class User:
raise HTTPException(status_code=401,detail="User is inactive")
if not user.is_active:
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)
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):
try:
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"))
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")
if not user.is_approved:
raise HTTPException(status_code=403,detail="Your Approval is at Pending")
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_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', {
// `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
@ -28,6 +28,8 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
// Same for `application_status`: omit for every tab (server defaults to
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
// 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: {
search,
top,
@ -37,6 +39,7 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
application_status: applicationStatus,
assigned,
is_duplicate: isDuplicate,
no_suggestions: noSuggestions,
},
})
}

View File

@ -143,49 +143,74 @@ export function toAtsScore(res) {
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) {
if (kind === 'manual') {
const id = row.id
return {
id: `manual:${row.id}`,
id: id != null ? `manual:${id}` : `manual:${row.user_id ?? row.email ?? 'unknown'}`,
inboxId: null,
manualUploadId: row.id,
manualUploadId: id ?? null,
jobId: row.job_post_id ?? null,
jobTitle: row.title ?? null,
currentTitle: row.current_position || null,
currentCompany: row.current_company || null,
source: row.platform || row.apply_via || 'Manual',
jobTitle: asText(row.title),
currentTitle: asText(row.current_position),
currentCompany: asText(row.current_company),
source: asText(row.platform) || asText(row.apply_via) || 'Manual',
}
}
return {
id: row.inbox_id,
inboxId: row.inbox_id,
id: row.inbox_id ?? `inbox:${row.user_id ?? row.email ?? 'unknown'}`,
inboxId: row.inbox_id ?? null,
manualUploadId: null,
jobId: row.assigned_job_post_id ?? null,
jobTitle: row.title ?? null,
currentTitle: row.current_title || null,
currentCompany: row.current_employment || null,
jobTitle: asText(row.title),
currentTitle: asText(row.current_title),
currentCompany: asText(row.current_employment),
source: null,
}
}
/**
* Pipeline inbox or manual-upload row -> one kanban card.
*
* Inbox `id` is the inbox id, not the user id: the board is one card per
* APPLICATION. `userId` rides along for the deep link into the profile.
* 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
* is one card per APPLICATION. `userId` rides along for the profile deep link.
*/
export function toBoardCard(row, kind = 'inbox') {
if (!row || typeof row !== 'object') return null
const src = sourceFields(row, kind)
const status = statusKey(row.application_status)
return {
...src,
userId: row.user_id ?? null,
name: row.name || row.email || 'Unknown',
email: row.email ?? null,
stage: STAGE_FROM_STATUS[row.application_status] ?? 'Shortlist',
status: row.application_status ?? null,
experience: row.experience || null,
aiScore: row.ats_result?.overall_score ?? null,
recommendation: row.ats_result?.band ?? null,
name: asText(row.name) || asText(row.email) || 'Unknown',
email: asText(row.email),
stage: STAGE_FROM_STATUS[status] ?? 'Shortlist',
status,
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,
}
}

View File

@ -17,14 +17,14 @@ export function firstKey(filePath) {
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) {
const raw = firstKey(value)
if (!raw) return false
if (/^https?:\/\//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. */
@ -50,7 +50,7 @@ export function resumeKeyFrom(item) {
* 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
* 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 } = {}) {
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 } })
}
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) {
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'];
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 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; }
@ -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' }); }
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 ----------
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.'
case 401:
return fromServer || 'Incorrect email or password.'
case 403:
return fromServer || 'Your Approval is at Pending'
case 409:
return fromServer || 'An account with that email already exists.'
case 502:

View File

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

View File

@ -14,7 +14,7 @@ const titles = {
const subtitles = {
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.',
}
@ -44,11 +44,16 @@ export default function ConfirmEmail() {
const data = res?.data || {}
if (data.email) setEmail(data.email)
setState('success')
const needsApproval = data.is_approved === false
setAlert({
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 is confirmed. You can sign in now.',
: 'Your email is confirmed. You can sign in now.'),
})
} catch (err) {
setState('error')

View File

@ -92,7 +92,7 @@ export default function Signup() {
return (
<AuthLayout
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={
<>
Already confirmed?{' '}

View File

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

View File

@ -1,8 +1,9 @@
/* ============================================================
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
land here as a radiogroup; Assign writes assigned_job_post_id. Nothing is
Queue layout mirrors Inbox (Tabs over a .split). Page size defaults to 10;
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
inbox nav badge.
============================================================ */
@ -15,6 +16,7 @@ import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import OpenResumeButton from '../ui/OpenResumeButton'
import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs'
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
import { useToast } from '../ui/Toast'
@ -38,10 +40,13 @@ const TABS = [
const TAB_FILTERS = {
needs: { assigned: false },
assigned: { assigned: true },
none: {},
none: { assigned: false, noSuggestions: true },
all: {},
}
/** Same cap as GET /inbox/all-applications `top`. */
const PAGE_SIZE_MAX = 500
const RESUME_STATUS = {
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
@ -175,6 +180,8 @@ export default function Matching() {
const deepLink = searchParams.get('record')
const [tab, setTab] = useState('needs')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [selectedId, setSelectedId] = useState(deepLink || null)
const [q, setQ] = useState('')
const [selection, setSelection] = useState(null)
@ -183,10 +190,16 @@ export default function Matching() {
const [whyOpen, setWhyOpen] = useState(false)
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({
queryKey: qk.mailbox.assignments({ ...tabFilter, tab }),
queryFn: () => fetchApplications(tabFilter),
queryKey: qk.mailbox.assignments({ ...listParams, tab }),
queryFn: () => fetchApplications(listParams),
})
const needsCount = useQuery({
@ -203,29 +216,21 @@ export default function Matching() {
})
const noneCountQuery = useQuery({
queryKey: qk.mailbox.assignments({ kind: 'none' }),
queryFn: async () => {
const res = await fetchApplications({})
return res.rows.filter((r) => !r.assignedId && r.suggestedIds.length === 0).length
},
queryFn: async () => (await inboxApi.listApplications({ assigned: false, noSuggestions: true, top: 1 }))?.total ?? 0,
})
const rows = listQuery.data?.rows ?? []
const filtered = useMemo(() => {
let list = rows
if (tab === 'none') {
list = list.filter((r) => !r.assignedId && r.suggestedIds.length === 0)
}
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 filtered = rows
const total = listQuery.data?.total ?? 0
const pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(page, pages)
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.
useEffect(() => {
if (!deepLink) return undefined
@ -395,7 +400,7 @@ export default function Matching() {
<div style={{ padding: '0 8px', borderBottom: '1px solid var(--border)' }}>
<Tabs
value={tab}
onChange={(t) => { setTab(t); setSelectedId(null) }}
onChange={(t) => { setTab(t); setPage(1); setSelectedId(null) }}
className="tabs tabs-wrap"
tabs={TABS.map((t) => ({
key: t.key,
@ -406,11 +411,15 @@ export default function Matching() {
</div>
<div className="split">
<div className="split-list">
<div className="split-list inbox-queue">
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
<div className="toolbar-search" style={{ maxWidth: 'none' }}>
<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>
@ -447,6 +456,24 @@ export default function Matching() {
</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 className="split-detail">

View File

@ -56,14 +56,28 @@ function byScoreDesc(a, b) {
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) {
const res = await pipelineApi.listApplications({ jobId, limit: BOARD_LIMIT })
const inbox = Array.isArray(res?.data?.inbox) ? res.data.inbox : []
const manuals = Array.isArray(res?.data?.manual_upload) ? res.data.manual_upload : []
return {
cards: [
...inbox.map((row) => pipelineApi.toBoardCard(row)),
...manuals.map((row) => pipelineApi.toManualBoardCard(row)),
...mapCards(inbox, (row) => pipelineApi.toBoardCard(row)),
...mapCards(manuals, (row) => pipelineApi.toManualBoardCard(row)),
].sort(byScoreDesc),
total: res?.total ?? 0,
stageCounts: pipelineApi.toStageCounts(res?.counts?.by_status),
@ -73,7 +87,12 @@ async function fetchBoard(jobId) {
async function fetchJobs() {
const res = await jobPostsApi.list({ activeOnly: true, top: JOB_LIMIT })
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),
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
viewer gets a read-only board instead of drags that 403 on drop. */
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 stageCounts = board.data?.stageCounts ?? {}
const stageCounts = board.data?.stageCounts && typeof board.data.stageCounts === 'object'
? board.data.stageCounts
: {}
const byStage = useMemo(() => {
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
}, [candidates])
@ -134,9 +161,9 @@ export default function Pipeline() {
await qc.cancelQueries({ queryKey: boardKey })
const previous = qc.getQueryData(boardKey)
qc.setQueryData(boardKey, (old) => {
if (!old) return old
if (!old || !Array.isArray(old.cards)) return old
const from = card.stage
const nextCounts = { ...old.stageCounts }
const nextCounts = { ...(old.stageCounts || {}) }
if (from && from !== stage) {
nextCounts[from] = Math.max(0, (nextCounts[from] ?? 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)}>
<option value="">All Jobs</option>
{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>
<button

View File

@ -32,7 +32,7 @@ function humaniseSlug(slug) {
}
const TABS = [
'General', 'Users', 'Permissions', 'Notifications',
'General', 'Users', 'Approvals', 'Permissions', 'Notifications',
'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance',
]
@ -114,6 +114,12 @@ export default function Settings() {
const canConfigure = can('settings.configure')
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 (
<div className="page">
<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">
{tab === 'General' && <General registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Users' && <Users />}
{tab === 'Approvals' && <Approvals />}
{tab === 'Permissions' && <Permissions />}
{tab === 'Notifications' && <Notifications registerSave={(fn) => { saveRef.current = fn }} />}
{tab === 'Email Templates' && <EmailTemplates />}
@ -308,7 +323,11 @@ function Users() {
</div>
</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 style={{ textAlign: 'right' }}>
<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 }) {
const { toast } = useToast()
const { can } = usePermission()

View File

@ -10,8 +10,9 @@ import Chart from './Chart'
import { avatarColor, initials as initialsOf } from '../data/seed'
export function Avatar({ name = '', initials, color, className = '' }) {
const bg = color || avatarColor(name || '')
const text = initials || initialsOf(name || '?')
const label = typeof name === 'string' ? name : String(name ?? '')
const bg = color || avatarColor(label)
const text = (typeof initials === 'string' && initials) || initialsOf(label || '?')
return (
<span className={`avatar ${className}`} style={{ background: bg }}>
{text}
@ -53,15 +54,19 @@ export function Badge({ children, className }) {
}
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.
const color =
score >= 85 ? 'var(--success)'
: score >= 70 ? 'var(--warning)'
: score >= 55 ? 'var(--info)' : 'var(--danger)'
pct >= 85 ? 'var(--success)'
: pct >= 70 ? 'var(--warning)'
: pct >= 55 ? 'var(--info)' : 'var(--danger)'
return (
<span className="score">
<span className="score-ring" style={{ '--pct': score, '--sc-color': color }}>
<span style={{ color }}>{score}</span>
<span className="score-ring" style={{ '--pct': pct, '--sc-color': color }}>
<span style={{ color }}>{shown}</span>
</span>
</span>
)