diff --git a/backend/alembic_setup.py b/backend/alembic_setup.py index bce75d1..61f1581 100644 --- a/backend/alembic_setup.py +++ b/backend/alembic_setup.py @@ -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"): - print(await autogenerate(args.message) or "no changes") + 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": diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 5fc5e70..3f05929 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -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 diff --git a/backend/inbox/models.py b/backend/inbox/models.py index d6a5230..8536d41 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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() diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 6c03e0a..4b85e1f 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -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) diff --git a/backend/job/app.py b/backend/job/app.py index 672888a..6036a95 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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: diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 63c0966..c7a8a13 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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)) diff --git a/backend/migrations/manual/013_user_is_approved.sql b/backend/migrations/manual/013_user_is_approved.sql new file mode 100644 index 0000000..7d7723a --- /dev/null +++ b/backend/migrations/manual/013_user_is_approved.sql @@ -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; diff --git a/backend/migrations/manual/014_cv_bank_files_file_path.sql b/backend/migrations/manual/014_cv_bank_files_file_path.sql new file mode 100644 index 0000000..e8d2465 --- /dev/null +++ b/backend/migrations/manual/014_cv_bank_files_file_path.sql @@ -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) <> ''; diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index 9eaa5c4..227b32c 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -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, } diff --git a/backend/s3/app.py b/backend/s3/app.py index d6ec5c1..de79f7f 100644 --- a/backend/s3/app.py +++ b/backend/s3/app.py @@ -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: diff --git a/backend/s3/plugins.py b/backend/s3/plugins.py index 644e054..eafd1dd 100644 --- a/backend/s3/plugins.py +++ b/backend/s3/plugins.py @@ -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: diff --git a/backend/users/app.py b/backend/users/app.py index fac26eb..47d5bbd 100644 --- a/backend/users/app.py +++ b/backend/users/app.py @@ -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)), diff --git a/backend/users/models.py b/backend/users/models.py index 9415e00..6a075cd 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -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.""" diff --git a/backend/users/permissions.py b/backend/users/permissions.py index e9b8a32..d85e61d 100644 --- a/backend/users/permissions.py +++ b/backend/users/permissions.py @@ -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) diff --git a/backend/users/plugins.py b/backend/users/plugins.py index 68dc4d6..8e083b2 100644 --- a/backend/users/plugins.py +++ b/backend/users/plugins.py @@ -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") diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 1b104b7..4c89c0c 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -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, diff --git a/backend/users/views.py b/backend/users/views.py index 55d6dfc..4abd595 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -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 diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index cb2e16b..fed258d 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -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, }, }) } diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js index e96a755..dea298b 100644 --- a/frontend/src/api/pipeline.js +++ b/frontend/src/api/pipeline.js @@ -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, } } diff --git a/frontend/src/api/s3.js b/frontend/src/api/s3.js index 1522725..d77043d 100644 --- a/frontend/src/api/s3.js +++ b/frontend/src/api/s3.js @@ -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) diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js index d5e4217..a0c112a 100644 --- a/frontend/src/api/users.js +++ b/frontend/src/api/users.js @@ -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 }) } diff --git a/frontend/src/data/seed.js b/frontend/src/data/seed.js index 1a976ae..a5a86dc 100644 --- a/frontend/src/data/seed.js +++ b/frontend/src/data/seed.js @@ -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 = []; diff --git a/frontend/src/lib/errors.js b/frontend/src/lib/errors.js index 5d96b59..cb70595 100644 --- a/frontend/src/lib/errors.js +++ b/frontend/src/lib/errors.js @@ -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: diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 562c434..4ac7797 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -9,6 +9,7 @@ export const qk = { users: { all: () => ['users'], list: (p = {}) => ['users', 'list', p], + pendingApprovals: () => ['users', 'pending-approvals'], }, roles: { all: () => ['roles'], diff --git a/frontend/src/pages/ConfirmEmail.jsx b/frontend/src/pages/ConfirmEmail.jsx index 21ca8eb..46e6dc5 100644 --- a/frontend/src/pages/ConfirmEmail.jsx +++ b/frontend/src/pages/ConfirmEmail.jsx @@ -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 - ? 'Your email was already confirmed. You can sign in.' - : 'Your email is confirmed. You can sign in now.', + 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.'), }) } catch (err) { setState('error') diff --git a/frontend/src/pages/Signup.jsx b/frontend/src/pages/Signup.jsx index 8ae08d8..376bc7a 100644 --- a/frontend/src/pages/Signup.jsx +++ b/frontend/src/pages/Signup.jsx @@ -92,7 +92,7 @@ export default function Signup() { return ( Already confirmed?{' '} diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx index 5c46de5..acb08e7 100644 --- a/frontend/src/screens/CvImport.jsx +++ b/frontend/src/screens/CvImport.jsx @@ -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()}` : ''} + {r.file_path ? ( +
+ {r.file_path} +
+ ) : ( +
No S3 path yet
+ )}
+ diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx index 97de9e7..a5d37b6 100644 --- a/frontend/src/screens/Matching.jsx +++ b/frontend/src/screens/Matching.jsx @@ -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() {
{ 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() {
-
+
- setQ(e.target.value)} placeholder="Search…" /> + { setQ(e.target.value); setPage(1) }} + placeholder="Search…" + />
@@ -447,6 +456,24 @@ export default function Matching() {
))}
+ {listQuery.isSuccess && total > 0 && ( + { 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) + }} + /> + )}
diff --git a/frontend/src/screens/Pipeline.jsx b/frontend/src/screens/Pipeline.jsx index 7ea528a..86794ac 100644 --- a/frontend/src/screens/Pipeline.jsx +++ b/frontend/src/screens/Pipeline.jsx @@ -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() {