is_approved, and file_pat aded
parent
7921b54141
commit
e4c5ffec20
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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) <> '';
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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?{' '}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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="Couldn’t 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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue