Implement candidate history tracking and enhance email URL configuration
- Added a new `CandidateHistory` model to maintain an append-only audit log for candidate actions. - Introduced `HistoryRecorder` functionality to log significant events across various modules, including interviews, feedback, and notes. - Updated existing methods to include `current_user` for tracking who performed actions. - Enhanced the `EMAIL_URL` configuration in `docker-compose.yml` to allow for environment variable overrides. - Updated frontend components to support fetching and displaying candidate history. This commit improves the application's ability to track changes and actions related to candidates, enhancing accountability and transparency.pull/20/head
parent
d6d929faf6
commit
c7fc2fed10
|
|
@ -38,6 +38,7 @@ async def create_calendar_event(
|
||||||
data=await service.create_for_interview(
|
data=await service.create_for_interview(
|
||||||
interview_id,
|
interview_id,
|
||||||
duration_minutes=body.duration_minutes,
|
duration_minutes=body.duration_minutes,
|
||||||
|
current_user=current_user,
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -59,6 +60,7 @@ async def reschedule_calendar_event(
|
||||||
interview_id,
|
interview_id,
|
||||||
instant=payload.instant,
|
instant=payload.instant,
|
||||||
duration_minutes=payload.duration_minutes,
|
duration_minutes=payload.duration_minutes,
|
||||||
|
current_user=current_user,
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -77,7 +79,9 @@ async def cancel_calendar_event(
|
||||||
try:
|
try:
|
||||||
body=payload or CalendarCancelBody()
|
body=payload or CalendarCancelBody()
|
||||||
service=Calendar(session=session)
|
service=Calendar(session=session)
|
||||||
data=await service.cancel_for_interview(interview_id,comment=body.comment)
|
data=await service.cancel_for_interview(
|
||||||
|
interview_id,comment=body.comment,current_user=current_user,
|
||||||
|
)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from interview.plugins import CALENDAR_API_TOKEN,cancel_event,create_event,reschedule_event
|
from interview.plugins import CALENDAR_API_TOKEN,cancel_event,create_event,reschedule_event
|
||||||
from interview.serializers import serialize_event
|
from interview.serializers import serialize_event
|
||||||
from job.candidate.models import Interviews
|
from job.candidate.models import Interviews
|
||||||
|
from job.history.enums import HistoryEvent
|
||||||
|
from job.history.views import HistoryRecorder
|
||||||
from job.interviews.serializers import serialize_interview
|
from job.interviews.serializers import serialize_interview
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
|
@ -72,7 +74,7 @@ class Calendar:
|
||||||
async def _serialize(self,row):
|
async def _serialize(self,row):
|
||||||
return serialize_interview(row,job_title=await self._job_title(row))
|
return serialize_interview(row,job_title=await self._job_title(row))
|
||||||
|
|
||||||
async def create_for_interview(self,interview_id,duration_minutes=None):
|
async def create_for_interview(self,interview_id,duration_minutes=None,current_user=None):
|
||||||
row=await self._load_interview(interview_id)
|
row=await self._load_interview(interview_id)
|
||||||
if row.graph_event_id:
|
if row.graph_event_id:
|
||||||
return await self._serialize(row)
|
return await self._serialize(row)
|
||||||
|
|
@ -121,13 +123,21 @@ class Calendar:
|
||||||
row=await Interviews.set_calendar_event(
|
row=await Interviews.set_calendar_event(
|
||||||
self.session,interview_id,event["id"],event.get("web_link"),
|
self.session,interview_id,event["id"],event.get("web_link"),
|
||||||
)
|
)
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.CALENDAR_CREATED.value,
|
||||||
|
current_user=current_user,inbox_id=row.inbox_id,
|
||||||
|
entity_type="interview",entity_id=row.id,
|
||||||
|
to_value=row.graph_event_id,
|
||||||
|
description=f"Calendar invite sent to {email}",commit=True,
|
||||||
|
)
|
||||||
return await self._serialize(row)
|
return await self._serialize(row)
|
||||||
|
|
||||||
async def reschedule_for_interview(self,interview_id,instant,duration_minutes=None):
|
async def reschedule_for_interview(self,interview_id,instant,duration_minutes=None,current_user=None):
|
||||||
row=await self._load_interview(interview_id)
|
row=await self._load_interview(interview_id)
|
||||||
if not row.graph_event_id:
|
if not row.graph_event_id:
|
||||||
raise HTTPException(status_code=404,detail="No calendar event for this interview")
|
raise HTTPException(status_code=404,detail="No calendar event for this interview")
|
||||||
|
|
||||||
|
old_instant=row.interview_date or row.interview_time
|
||||||
start_dt=_as_datetime(instant)
|
start_dt=_as_datetime(instant)
|
||||||
if start_dt is None:
|
if start_dt is None:
|
||||||
raise HTTPException(status_code=422,detail="instant is required")
|
raise HTTPException(status_code=422,detail="instant is required")
|
||||||
|
|
@ -157,13 +167,21 @@ class Calendar:
|
||||||
if event.get("web_link"):
|
if event.get("web_link"):
|
||||||
fields["web_link"]=event["web_link"]
|
fields["web_link"]=event["web_link"]
|
||||||
row=await Interviews.update_interview(self.session,interview_id,fields)
|
row=await Interviews.update_interview(self.session,interview_id,fields)
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.CALENDAR_RESCHEDULED.value,
|
||||||
|
current_user=current_user,inbox_id=row.inbox_id,
|
||||||
|
entity_type="interview",entity_id=row.id,
|
||||||
|
from_value=old_instant.isoformat() if old_instant else None,
|
||||||
|
to_value=start_dt.isoformat(),commit=True,
|
||||||
|
)
|
||||||
return await self._serialize(row)
|
return await self._serialize(row)
|
||||||
|
|
||||||
async def cancel_for_interview(self,interview_id,comment=None):
|
async def cancel_for_interview(self,interview_id,comment=None,current_user=None):
|
||||||
row=await self._load_interview(interview_id)
|
row=await self._load_interview(interview_id)
|
||||||
if not row.graph_event_id:
|
if not row.graph_event_id:
|
||||||
return await self._serialize(row)
|
return await self._serialize(row)
|
||||||
|
|
||||||
|
old_event_id=row.graph_event_id
|
||||||
try:
|
try:
|
||||||
await cancel_event(row.graph_event_id,comment=comment,token=self.token)
|
await cancel_event(row.graph_event_id,comment=comment,token=self.token)
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
|
|
@ -172,4 +190,11 @@ class Calendar:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
row=await Interviews.set_calendar_event(self.session,interview_id,None,None)
|
row=await Interviews.set_calendar_event(self.session,interview_id,None,None)
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.CALENDAR_CANCELLED.value,
|
||||||
|
current_user=current_user,inbox_id=row.inbox_id,
|
||||||
|
entity_type="interview",entity_id=row.id,
|
||||||
|
from_value=old_event_id,to_value=None,
|
||||||
|
description=comment,commit=True,
|
||||||
|
)
|
||||||
return await self._serialize(row)
|
return await self._serialize(row)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from job.notes.views import Note
|
||||||
from job.activity.views import ActivityLog
|
from job.activity.views import ActivityLog
|
||||||
from job.feedback.views import FeedbackView
|
from job.feedback.views import FeedbackView
|
||||||
from job.pipeline.views import Pipeline
|
from job.pipeline.views import Pipeline
|
||||||
|
from job.history.views import HistoryRecorder
|
||||||
from job.assignment.views import Assignment
|
from job.assignment.views import Assignment
|
||||||
from job.cost.views import HiringCost
|
from job.cost.views import HiringCost
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -245,6 +246,7 @@ async def cv_upload(
|
||||||
service=FileRead(session=session,filename=file.filename,file=file_content)
|
service=FileRead(session=session,filename=file.filename,file=file_content)
|
||||||
data=await service.ingest_upload(
|
data=await service.ingest_upload(
|
||||||
candidate_email=candidate_email,candidate_name=candidate_name,
|
candidate_email=candidate_email,candidate_name=candidate_name,
|
||||||
|
current_user=current_user,
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":data,"status_code":200})
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -261,7 +263,7 @@ async def candidate_inbox_match(
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=FileRead(session=session)
|
service=FileRead(session=session)
|
||||||
data=await service.match_inbox_cv(inbox_message_id)
|
data=await service.match_inbox_cv(inbox_message_id,current_user=current_user)
|
||||||
return JSONResponse(content={"data":data,"status_code":200})
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -512,7 +514,7 @@ async def update_candidate(
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=CandidateView(session=session)
|
service=CandidateView(session=session)
|
||||||
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True))
|
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True),current_user)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -520,6 +522,24 @@ async def update_candidate(
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/candidate/history/fetch")
|
||||||
|
async def fetch_candidate_history(
|
||||||
|
user_id:str=Query(...),
|
||||||
|
limit:int=Query(200,ge=1,le=500),
|
||||||
|
offset:int=Query(0,ge=0),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
service=HistoryRecorder(session=session)
|
||||||
|
data,total=await service.list_for_user(user_id,limit=limit,offset=offset)
|
||||||
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/interview/fetch")
|
@router.get("/interview/fetch")
|
||||||
async def fetch_interview(
|
async def fetch_interview(
|
||||||
interview_id:str=Query(None),
|
interview_id:str=Query(None),
|
||||||
|
|
@ -562,7 +582,7 @@ async def create_interview(
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Interview(session=session)
|
service=Interview(session=session)
|
||||||
data=await service.create_interview(payload.model_dump(exclude_unset=True))
|
data=await service.create_interview(payload.model_dump(exclude_unset=True),current_user)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -579,7 +599,7 @@ async def update_interview(
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Interview(session=session)
|
service=Interview(session=session)
|
||||||
data=await service.update_interview(interview_id,payload.model_dump(exclude_unset=True))
|
data=await service.update_interview(interview_id,payload.model_dump(exclude_unset=True),current_user)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -630,7 +650,7 @@ async def update_note(
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Note(session=session)
|
service=Note(session=session)
|
||||||
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True))
|
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True),current_user)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -723,7 +743,7 @@ async def update_feedback(
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=FeedbackView(session=session)
|
service=FeedbackView(session=session)
|
||||||
data=await service.update_feedback(feedback_id,payload.model_dump(exclude_unset=True))
|
data=await service.update_feedback(feedback_id,payload.model_dump(exclude_unset=True),current_user)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import JSON, DateTime, func, UniqueConstraint
|
from sqlalchemy import JSON, DateTime, Index, func, UniqueConstraint
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
@ -797,4 +797,71 @@ class ApplicationStageTransitions(SQLModel, table=True):
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateHistory(SQLModel, table=True):
|
||||||
|
"""Append-only audit log for one candidate (users.id), scoped to an application.
|
||||||
|
|
||||||
|
user_id is the anchor: the profile modal is keyed by users.id, so every read is
|
||||||
|
one indexed scan. inbox_id / manual_upload_candidate_id record WHICH application
|
||||||
|
the event happened on and are BOTH nullable -- unlike application_stage_transitions,
|
||||||
|
several events (notes, rating changes, an import before the inbox link exists) have
|
||||||
|
neither. That is why there is no XOR check constraint here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "candidate_history"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_candidate_history_user_created", "user_id", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
user_id: uuid.UUID = Field(foreign_key="users.id", nullable=False)
|
||||||
|
inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id")
|
||||||
|
manual_upload_candidate_id: uuid.UUID | None = Field(
|
||||||
|
default=None, index=True, foreign_key="manual_upload_candidate.id"
|
||||||
|
)
|
||||||
|
event_type: str = Field(index=True)
|
||||||
|
entity_type: str | None = Field(default=None)
|
||||||
|
entity_id: str | None = Field(default=None)
|
||||||
|
from_value: str | None = Field(default=None)
|
||||||
|
to_value: str | None = Field(default=None)
|
||||||
|
description: str | None = Field(default=None)
|
||||||
|
actor_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||||
|
actor_kind: str = Field(default="user")
|
||||||
|
meta: dict | None = Field(default=None, sa_type=JSON)
|
||||||
|
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||||
|
if record_id in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(record_id))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def fetch_by_user(cls, session: AsyncSession, user_id, limit=200, offset=0):
|
||||||
|
uid = cls._as_uuid(user_id)
|
||||||
|
if uid is None:
|
||||||
|
return [], 0
|
||||||
|
total_stmt = select(func.count()).select_from(cls).where(cls.user_id == uid)
|
||||||
|
total = (await session.execute(total_stmt)).scalar_one()
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(cls.user_id == uid)
|
||||||
|
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||||
|
.offset(int(offset))
|
||||||
|
.limit(int(limit))
|
||||||
|
)
|
||||||
|
return list(result.scalars().all()), int(total)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def insert_event(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
|
||||||
|
row = cls(**fields)
|
||||||
|
session.add(row)
|
||||||
|
if commit:
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
import users.models as _users_models # noqa: E402, F401
|
import users.models as _users_models # noqa: E402, F401
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ def serialize_candidate_profile(
|
||||||
message = link.messages
|
message = link.messages
|
||||||
payload = {
|
payload = {
|
||||||
"inbox_id": link.id,
|
"inbox_id": link.id,
|
||||||
|
"manual_upload_candidate_id": None,
|
||||||
"user_id": str(link.user_id) if link.user_id else None,
|
"user_id": str(link.user_id) if link.user_id else None,
|
||||||
"candidate_id": None,
|
"candidate_id": None,
|
||||||
"name": user.name if user else None,
|
"name": user.name if user else None,
|
||||||
|
|
@ -139,6 +140,7 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
||||||
position = (row.current_position or "").strip() or None
|
position = (row.current_position or "").strip() or None
|
||||||
return {
|
return {
|
||||||
"inbox_id": None,
|
"inbox_id": None,
|
||||||
|
"manual_upload_candidate_id": str(row.id),
|
||||||
"user_id": str(user.id) if user else (str(row.user_id) if row.user_id else None),
|
"user_id": str(user.id) if user else (str(row.user_id) if row.user_id else None),
|
||||||
"candidate_id": None,
|
"candidate_id": None,
|
||||||
"name": (user.name if user else None) or row.candidate_name or None,
|
"name": (user.name if user else None) or row.candidate_name or None,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ from job.candidate.serializers import serialize_candidate,serialize_candidate_pr
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
from job.job_post.serializers import serialize_job_post
|
from job.job_post.serializers import serialize_job_post
|
||||||
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
|
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
|
||||||
|
from job.history.enums import HistoryEvent
|
||||||
|
from job.history.views import HistoryRecorder
|
||||||
from job.notes.serializers import serialize_note
|
from job.notes.serializers import serialize_note
|
||||||
from job.candidate.plugins import extract_candidate_email
|
from job.candidate.plugins import extract_candidate_email
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
|
@ -123,7 +125,7 @@ class FileRead:
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning("could not remove orphaned upload %s: %s",file_path,e)
|
logger.warning("could not remove orphaned upload %s: %s",file_path,e)
|
||||||
|
|
||||||
async def ingest_upload(self,candidate_email=None,candidate_name=None):
|
async def ingest_upload(self,candidate_email=None,candidate_name=None,current_user=None):
|
||||||
"""Persist a recruiter-uploaded CV with full email-ingestion parity."""
|
"""Persist a recruiter-uploaded CV with full email-ingestion parity."""
|
||||||
from inbox.file_decoder import AttachmentDecodeError,decode_attachment
|
from inbox.file_decoder import AttachmentDecodeError,decode_attachment
|
||||||
from inbox.cv_tasks import match_uploaded_cv
|
from inbox.cv_tasks import match_uploaded_cv
|
||||||
|
|
@ -196,6 +198,23 @@ class FileRead:
|
||||||
logger.warning("account setup mail failed for %s: %s",new_user_email,e)
|
logger.warning("account setup mail failed for %s: %s",new_user_email,e)
|
||||||
account_setup=[{"email":new_user_email,"sent":False}]
|
account_setup=[{"email":new_user_email,"sent":False}]
|
||||||
|
|
||||||
|
# Inbox link may not exist yet (match task creates it later); resolve
|
||||||
|
# by email because insert_email creates the Users row synchronously.
|
||||||
|
user=await Users.get_user_by_email(self.session,email)
|
||||||
|
if user:
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.CANDIDATE_IMPORTED.value,
|
||||||
|
current_user=current_user,user_id=user.id,
|
||||||
|
entity_type="inbox_message",entity_id=row.id,
|
||||||
|
to_value=email,description=f"CV uploaded: {filename}",commit=True,
|
||||||
|
)
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.DOCUMENT_UPLOADED.value,
|
||||||
|
current_user=current_user,user_id=user.id,
|
||||||
|
entity_type="document",entity_id=row.id,
|
||||||
|
to_value=filename,commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"queued":True,
|
"queued":True,
|
||||||
"inbox_message_id":str(row.id),
|
"inbox_message_id":str(row.id),
|
||||||
|
|
@ -208,7 +227,7 @@ class FileRead:
|
||||||
"text":text,
|
"text":text,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def match_inbox_cv(self,inbox_message_id):
|
async def match_inbox_cv(self,inbox_message_id,current_user=None):
|
||||||
from inbox.plugins import resolve_attachment_path
|
from inbox.plugins import resolve_attachment_path
|
||||||
from inbox.tasks import match_inbox_message
|
from inbox.tasks import match_inbox_message
|
||||||
|
|
||||||
|
|
@ -235,6 +254,12 @@ class FileRead:
|
||||||
).kiq(str(row.id),force=True)
|
).kiq(str(row.id),force=True)
|
||||||
|
|
||||||
file_name=(row.file_name or "").split(",")[0].strip() or found.name
|
file_name=(row.file_name or "").split(",")[0].strip() or found.name
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.CANDIDATE_IMPORTED.value,
|
||||||
|
current_user=current_user,message_id=inbox_message_id,
|
||||||
|
entity_type="inbox_message",entity_id=row.id,
|
||||||
|
to_value=file_name,description=f"CV uploaded: {file_name}",commit=True,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"queued":True,
|
"queued":True,
|
||||||
"inbox_message_id":str(row.id),
|
"inbox_message_id":str(row.id),
|
||||||
|
|
@ -358,7 +383,7 @@ class CandidateScoring:
|
||||||
rows=[]
|
rows=[]
|
||||||
for slot in range(len(sources)):
|
for slot in range(len(sources)):
|
||||||
rows.append(await Candidates.upsert_candidate(self.session,{**fields_by_slot[slot],**common}))
|
rows.append(await Candidates.upsert_candidate(self.session,{**fields_by_slot[slot],**common}))
|
||||||
await self._sync_ats_results(source_kind,job,rows,sources)
|
await self._sync_ats_results(source_kind,job,rows,sources,current_user)
|
||||||
rows.sort(key=lambda r:(0,-(r.match_score or 0)) if r.status=="completed" else (1,0))
|
rows.sort(key=lambda r:(0,-(r.match_score or 0)) if r.status=="completed" else (1,0))
|
||||||
return [serialize_candidate(row) for row in rows]
|
return [serialize_candidate(row) for row in rows]
|
||||||
|
|
||||||
|
|
@ -401,7 +426,7 @@ class CandidateScoring:
|
||||||
fields_by_slot[slot]=candidate_failed_fields(source,result.error_code,result.error_message)
|
fields_by_slot[slot]=candidate_failed_fields(source,result.error_code,result.error_message)
|
||||||
return fields_by_slot
|
return fields_by_slot
|
||||||
|
|
||||||
async def _sync_ats_results(self,source_kind,job,rows,sources):
|
async def _sync_ats_results(self,source_kind,job,rows,sources,current_user=None):
|
||||||
if source_kind=="inbox":
|
if source_kind=="inbox":
|
||||||
best={}
|
best={}
|
||||||
for source,row in zip(sources,rows):
|
for source,row in zip(sources,rows):
|
||||||
|
|
@ -412,7 +437,7 @@ class CandidateScoring:
|
||||||
best[mid]=row
|
best[mid]=row
|
||||||
for message_id,row in best.items():
|
for message_id,row in best.items():
|
||||||
try:
|
try:
|
||||||
await self._sync_inbox_ats(message_id,job,row)
|
await self._sync_inbox_ats(message_id,job,row,current_user=current_user)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.session.rollback()
|
await self.session.rollback()
|
||||||
logger.exception("inbox ATS denorm failed for message %s",message_id)
|
logger.exception("inbox ATS denorm failed for message %s",message_id)
|
||||||
|
|
@ -421,12 +446,12 @@ class CandidateScoring:
|
||||||
if row.status!="completed":
|
if row.status!="completed":
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
await self._sync_upload_ats(job,row)
|
await self._sync_upload_ats(job,row,current_user=current_user)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.session.rollback()
|
await self.session.rollback()
|
||||||
logger.exception("upload ATS history failed for candidate %s",row.id)
|
logger.exception("upload ATS history failed for candidate %s",row.id)
|
||||||
|
|
||||||
async def _sync_inbox_ats(self,message_id,job,row):
|
async def _sync_inbox_ats(self,message_id,job,row,current_user=None):
|
||||||
"""Land a completed score on inbox_messages / inbox / ats_results.
|
"""Land a completed score on inbox_messages / inbox / ats_results.
|
||||||
|
|
||||||
message_id is the scoring call's known inbox_messages PK, not a column
|
message_id is the scoring call's known inbox_messages PK, not a column
|
||||||
|
|
@ -446,6 +471,8 @@ class CandidateScoring:
|
||||||
await Inbox_Messages.set_ats_score(self.session,message_id,row.match_score,band)
|
await Inbox_Messages.set_ats_score(self.session,message_id,row.match_score,band)
|
||||||
if link is None:
|
if link is None:
|
||||||
return
|
return
|
||||||
|
old=await AtsResults.get_current_for_inbox(self.session,link.id)
|
||||||
|
old_score=old.overall_score if old else None
|
||||||
identity=await AtsResults.resolve_identity(self.session,row.candidate_email,row.id)
|
identity=await AtsResults.resolve_identity(self.session,row.candidate_email,row.id)
|
||||||
await AtsResults.insert_result(self.session,{
|
await AtsResults.insert_result(self.session,{
|
||||||
"inbox_id":link.id,
|
"inbox_id":link.id,
|
||||||
|
|
@ -456,11 +483,24 @@ class CandidateScoring:
|
||||||
"model_name":row.model,
|
"model_name":row.model,
|
||||||
"is_current":True,
|
"is_current":True,
|
||||||
})
|
})
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.ATS_SCORED.value,
|
||||||
|
current_user=current_user,user_id=link.user_id,inbox_id=link.id,
|
||||||
|
entity_type="ats_result",entity_id=row.id,
|
||||||
|
from_value=old_score,to_value=row.match_score,
|
||||||
|
description=f"{band} against {job.title or 'job'}",commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
async def _sync_upload_ats(self,job,row):
|
async def _sync_upload_ats(self,job,row,current_user=None):
|
||||||
"""History row for an upload-sourced score — inbox_id stays NULL."""
|
"""History row for an upload-sourced score — inbox_id stays NULL."""
|
||||||
band=CandidateView._recommendation(row.match_score) or ""
|
band=CandidateView._recommendation(row.match_score) or ""
|
||||||
identity=await AtsResults.resolve_identity(self.session,row.candidate_email,row.id)
|
identity=await AtsResults.resolve_identity(self.session,row.candidate_email,row.id)
|
||||||
|
old=None
|
||||||
|
if identity.get("user_id"):
|
||||||
|
old=await AtsResults.get_current_for_user(self.session,identity["user_id"],job.id)
|
||||||
|
elif identity.get("candidate_id"):
|
||||||
|
old=await AtsResults.get_current_for_candidate(self.session,identity["candidate_id"])
|
||||||
|
old_score=old.overall_score if old else None
|
||||||
await AtsResults.insert_result(self.session,{
|
await AtsResults.insert_result(self.session,{
|
||||||
"inbox_id":None,
|
"inbox_id":None,
|
||||||
**identity,
|
**identity,
|
||||||
|
|
@ -470,7 +510,13 @@ class CandidateScoring:
|
||||||
"model_name":row.model,
|
"model_name":row.model,
|
||||||
"is_current":True,
|
"is_current":True,
|
||||||
})
|
})
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.ATS_SCORED.value,
|
||||||
|
current_user=current_user,user_id=identity.get("user_id"),
|
||||||
|
entity_type="ats_result",entity_id=row.id,
|
||||||
|
from_value=old_score,to_value=row.match_score,
|
||||||
|
description=f"{band} against {job.title or 'job'}",commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
class CandidateView:
|
class CandidateView:
|
||||||
def __init__(self,session:AsyncSession):
|
def __init__(self,session:AsyncSession):
|
||||||
|
|
@ -517,6 +563,22 @@ class CandidateView:
|
||||||
|
|
||||||
}
|
}
|
||||||
row=await Manual_UPLOAD_CANDIDATE.create_manual_upload_candidate(session=self.session,fields=data)
|
row=await Manual_UPLOAD_CANDIDATE.create_manual_upload_candidate(session=self.session,fields=data)
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.CANDIDATE_CREATED.value,
|
||||||
|
actor_id=current_user,user_id=row.user_id,
|
||||||
|
manual_upload_candidate_id=row.id,
|
||||||
|
entity_type="manual_upload_candidate",entity_id=row.id,
|
||||||
|
to_value=row.candidate_email,
|
||||||
|
description=(row.platform or "").strip() or "manual_upload",commit=True,
|
||||||
|
)
|
||||||
|
if (row.file_name or "").strip():
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.DOCUMENT_UPLOADED.value,
|
||||||
|
actor_id=current_user,user_id=row.user_id,
|
||||||
|
manual_upload_candidate_id=row.id,
|
||||||
|
entity_type="document",entity_id=row.id,
|
||||||
|
to_value=row.file_name,commit=True,
|
||||||
|
)
|
||||||
return serialize_manual_upload_candidate(row)
|
return serialize_manual_upload_candidate(row)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -569,7 +631,7 @@ class CandidateView:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
async def update_candidate(self,user_id,payload):
|
async def update_candidate(self,user_id,payload,current_user=None):
|
||||||
try:
|
try:
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=400,detail="user_id is required")
|
raise HTTPException(status_code=400,detail="user_id is required")
|
||||||
|
|
@ -580,8 +642,25 @@ class CandidateView:
|
||||||
records=links if isinstance(links,list) else ([links] if links else [])
|
records=links if isinstance(links,list) else ([links] if links else [])
|
||||||
if not records:
|
if not records:
|
||||||
raise HTTPException(status_code=404,detail="Candidate not found")
|
raise HTTPException(status_code=404,detail="Candidate not found")
|
||||||
|
first=records[0]
|
||||||
|
old_favorite=getattr(first,"favorite",None)
|
||||||
|
old_rating=getattr(first,"rating",None)
|
||||||
for link in records:
|
for link in records:
|
||||||
await Inbox.update_inbox(self.session,link.id,fields)
|
await Inbox.update_inbox(self.session,link.id,fields)
|
||||||
|
if "favorite" in fields and fields["favorite"]!=old_favorite:
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.FAVORITE_CHANGED.value,
|
||||||
|
current_user=current_user,user_id=user_id,inbox_id=first.id,
|
||||||
|
entity_type="candidate",entity_id=user_id,
|
||||||
|
from_value=old_favorite,to_value=fields["favorite"],commit=True,
|
||||||
|
)
|
||||||
|
if "rating" in fields and fields["rating"]!=old_rating:
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.RATING_CHANGED.value,
|
||||||
|
current_user=current_user,user_id=user_id,inbox_id=first.id,
|
||||||
|
entity_type="candidate",entity_id=user_id,
|
||||||
|
from_value=old_rating,to_value=fields["rating"],commit=True,
|
||||||
|
)
|
||||||
refreshed=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0)
|
refreshed=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0)
|
||||||
return await self.attach_profile_detail(refreshed)
|
return await self.attach_profile_detail(refreshed)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ from sqlmodel import select
|
||||||
from job.candidate.models import Feedback
|
from job.candidate.models import Feedback
|
||||||
from job.feedback.models import FeedbackTemplates
|
from job.feedback.models import FeedbackTemplates
|
||||||
from job.feedback.serializers import serialize_feedback, serialize_feedback_template
|
from job.feedback.serializers import serialize_feedback, serialize_feedback_template
|
||||||
|
from job.history.enums import HistoryEvent
|
||||||
|
from job.history.views import HistoryRecorder
|
||||||
|
|
||||||
|
|
||||||
class FeedbackView:
|
class FeedbackView:
|
||||||
|
|
@ -49,17 +51,39 @@ class FeedbackView:
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
row=await Feedback.insert_feedback(self.session,fields)
|
row=await Feedback.insert_feedback(self.session,fields)
|
||||||
|
desc=row.note or None
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.FEEDBACK_CREATED.value,
|
||||||
|
current_user=current_user,inbox_id=row.inbox_id,
|
||||||
|
entity_type="feedback",entity_id=row.id,
|
||||||
|
to_value=row.review or None,description=desc,commit=True,
|
||||||
|
)
|
||||||
row=await self._load(row.id)
|
row=await self._load(row.id)
|
||||||
return serialize_feedback(row)
|
return serialize_feedback(row)
|
||||||
|
|
||||||
async def update_feedback(self,feedback_id,payload):
|
async def update_feedback(self,feedback_id,payload,current_user=None):
|
||||||
allowed=("review","financial_status","score","note","inbox_id","reviewed_by")
|
allowed=("review","financial_status","score","note","inbox_id","reviewed_by")
|
||||||
fields={k:v for k,v in payload.items() if v is not None and k in allowed}
|
fields={k:v for k,v in payload.items() if v is not None and k in allowed}
|
||||||
if not fields:
|
if not fields:
|
||||||
raise HTTPException(status_code=400,detail="No fields to update")
|
raise HTTPException(status_code=400,detail="No fields to update")
|
||||||
|
before=await self._load(feedback_id)
|
||||||
|
if not before:
|
||||||
|
raise HTTPException(status_code=404,detail="Feedback not found")
|
||||||
|
old_review=before.review or ""
|
||||||
|
old_score=before.score
|
||||||
row=await Feedback.update_feedback(self.session,feedback_id,fields)
|
row=await Feedback.update_feedback(self.session,feedback_id,fields)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Feedback not found")
|
raise HTTPException(status_code=404,detail="Feedback not found")
|
||||||
|
desc=row.note
|
||||||
|
if old_score!=row.score:
|
||||||
|
desc=f"score {old_score} → {row.score}"
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.FEEDBACK_UPDATED.value,
|
||||||
|
current_user=current_user,inbox_id=row.inbox_id,
|
||||||
|
entity_type="feedback",entity_id=row.id,
|
||||||
|
from_value=old_review,to_value=row.review or None,
|
||||||
|
description=desc,commit=True,
|
||||||
|
)
|
||||||
row=await self._load(row.id)
|
row=await self._load(row.id)
|
||||||
return serialize_feedback(row)
|
return serialize_feedback(row)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryEvent(str, Enum):
|
||||||
|
STAGE_CHANGED = "stage.changed"
|
||||||
|
NOTE_CREATED = "note.created"
|
||||||
|
NOTE_UPDATED = "note.updated"
|
||||||
|
FEEDBACK_CREATED = "feedback.created"
|
||||||
|
FEEDBACK_UPDATED = "feedback.updated"
|
||||||
|
INTERVIEW_CREATED = "interview.created"
|
||||||
|
INTERVIEW_UPDATED = "interview.updated"
|
||||||
|
CALENDAR_CREATED = "calendar.created"
|
||||||
|
CALENDAR_RESCHEDULED = "calendar.rescheduled"
|
||||||
|
CALENDAR_CANCELLED = "calendar.cancelled"
|
||||||
|
FAVORITE_CHANGED = "favorite.changed"
|
||||||
|
RATING_CHANGED = "rating.changed"
|
||||||
|
CANDIDATE_CREATED = "candidate.created"
|
||||||
|
CANDIDATE_IMPORTED = "candidate.imported"
|
||||||
|
DOCUMENT_UPLOADED = "document.uploaded"
|
||||||
|
ATS_SCORED = "ats.scored"
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
def serialize_history(row, actor_name=None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"user_id": str(row.user_id) if row.user_id else None,
|
||||||
|
"inbox_id": row.inbox_id,
|
||||||
|
"manual_upload_candidate_id": (
|
||||||
|
str(row.manual_upload_candidate_id) if row.manual_upload_candidate_id else None
|
||||||
|
),
|
||||||
|
"event_type": row.event_type,
|
||||||
|
"entity_type": row.entity_type,
|
||||||
|
"entity_id": row.entity_id,
|
||||||
|
"from_value": row.from_value,
|
||||||
|
"to_value": row.to_value,
|
||||||
|
"description": row.description,
|
||||||
|
"actor_id": str(row.actor_id) if row.actor_id else None,
|
||||||
|
"actor_name": actor_name,
|
||||||
|
"actor_kind": row.actor_kind,
|
||||||
|
"meta": row.meta,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from inbox.models import Inbox
|
||||||
|
from job.candidate.models import CandidateHistory, Manual_UPLOAD_CANDIDATE
|
||||||
|
from job.history.serializers import serialize_history
|
||||||
|
from users.models import Users
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryRecorder:
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def resolve_user_id(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id=None,
|
||||||
|
inbox_id=None,
|
||||||
|
manual_upload_candidate_id=None,
|
||||||
|
message_id=None,
|
||||||
|
):
|
||||||
|
uid = CandidateHistory._as_uuid(user_id)
|
||||||
|
if uid is not None:
|
||||||
|
return uid
|
||||||
|
if inbox_id is not None:
|
||||||
|
row = await Inbox.get_inbox_by_id(self.session, inbox_id)
|
||||||
|
if row and row.user_id:
|
||||||
|
return row.user_id
|
||||||
|
if manual_upload_candidate_id is not None:
|
||||||
|
row = await Manual_UPLOAD_CANDIDATE.get_by_id(self.session, manual_upload_candidate_id)
|
||||||
|
if row and row.user_id:
|
||||||
|
return row.user_id
|
||||||
|
if message_id is not None:
|
||||||
|
row = await Inbox.get_inbox_by_message_id(self.session, message_id)
|
||||||
|
if row and row.user_id:
|
||||||
|
return row.user_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _actor_id(self, current_user=None, actor_id=None):
|
||||||
|
if actor_id is not None:
|
||||||
|
return CandidateHistory._as_uuid(actor_id)
|
||||||
|
if isinstance(current_user, dict) and current_user.get("id"):
|
||||||
|
return CandidateHistory._as_uuid(current_user.get("id"))
|
||||||
|
if current_user and not isinstance(current_user, dict):
|
||||||
|
return CandidateHistory._as_uuid(current_user)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def record(
|
||||||
|
self,
|
||||||
|
event_type,
|
||||||
|
*,
|
||||||
|
current_user=None,
|
||||||
|
actor_id=None,
|
||||||
|
user_id=None,
|
||||||
|
inbox_id=None,
|
||||||
|
manual_upload_candidate_id=None,
|
||||||
|
message_id=None,
|
||||||
|
entity_type=None,
|
||||||
|
entity_id=None,
|
||||||
|
from_value=None,
|
||||||
|
to_value=None,
|
||||||
|
description=None,
|
||||||
|
meta=None,
|
||||||
|
actor_kind=None,
|
||||||
|
commit=False,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
resolved = await self.resolve_user_id(
|
||||||
|
user_id=user_id,
|
||||||
|
inbox_id=inbox_id,
|
||||||
|
manual_upload_candidate_id=manual_upload_candidate_id,
|
||||||
|
message_id=message_id,
|
||||||
|
)
|
||||||
|
if resolved is None:
|
||||||
|
return None
|
||||||
|
fields = {
|
||||||
|
"user_id": resolved,
|
||||||
|
"inbox_id": int(inbox_id) if inbox_id is not None else None,
|
||||||
|
"manual_upload_candidate_id": CandidateHistory._as_uuid(manual_upload_candidate_id),
|
||||||
|
"event_type": event_type,
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"entity_id": str(entity_id) if entity_id is not None else None,
|
||||||
|
"from_value": _text(from_value),
|
||||||
|
"to_value": _text(to_value),
|
||||||
|
"description": description,
|
||||||
|
"actor_id": self._actor_id(current_user=current_user, actor_id=actor_id),
|
||||||
|
"actor_kind": actor_kind or "user",
|
||||||
|
"meta": meta,
|
||||||
|
}
|
||||||
|
return await CandidateHistory.insert_event(self.session, fields, commit=commit)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("candidate history record failed for %s", event_type)
|
||||||
|
if commit:
|
||||||
|
try:
|
||||||
|
await self.session.rollback()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("candidate history rollback failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def list_for_user(self, user_id, *, limit=200, offset=0):
|
||||||
|
rows, total = await CandidateHistory.fetch_by_user(
|
||||||
|
self.session, user_id, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
actor_ids = {r.actor_id for r in rows if r.actor_id}
|
||||||
|
names = {}
|
||||||
|
if actor_ids:
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(Users.id, Users.name).where(Users.id.in_(actor_ids))
|
||||||
|
)
|
||||||
|
names = {uid: name for uid, name in result.all()}
|
||||||
|
return [serialize_history(r, actor_name=names.get(r.actor_id)) for r in rows], total
|
||||||
|
|
@ -2,6 +2,8 @@ from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from job.candidate.models import Interviews
|
from job.candidate.models import Interviews
|
||||||
|
from job.history.enums import HistoryEvent
|
||||||
|
from job.history.views import HistoryRecorder
|
||||||
from job.interviews.serializers import serialize_interview
|
from job.interviews.serializers import serialize_interview
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
|
@ -49,7 +51,7 @@ class Interview:
|
||||||
titles=await Interviews.job_titles_by_inbox(self.session,[r.inbox_id for r in rows])
|
titles=await Interviews.job_titles_by_inbox(self.session,[r.inbox_id for r in rows])
|
||||||
return [serialize_interview(r,job_title=titles.get(r.inbox_id)) for r in rows],total
|
return [serialize_interview(r,job_title=titles.get(r.inbox_id)) for r in rows],total
|
||||||
|
|
||||||
async def create_interview(self,payload):
|
async def create_interview(self,payload,current_user=None):
|
||||||
fields={
|
fields={
|
||||||
"interview_date":payload.get("interview_date"),
|
"interview_date":payload.get("interview_date"),
|
||||||
"interview_time":payload.get("interview_time"),
|
"interview_time":payload.get("interview_time"),
|
||||||
|
|
@ -59,13 +61,34 @@ class Interview:
|
||||||
}
|
}
|
||||||
fields={k:v for k,v in fields.items() if v is not None}
|
fields={k:v for k,v in fields.items() if v is not None}
|
||||||
row=await Interviews.insert_interview(self.session,fields)
|
row=await Interviews.insert_interview(self.session,fields)
|
||||||
|
when=row.interview_date or row.interview_time
|
||||||
|
desc=f"{row.interview_type or 'Interview'} on {when.isoformat() if when else '—'}"
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.INTERVIEW_CREATED.value,
|
||||||
|
current_user=current_user,inbox_id=row.inbox_id,
|
||||||
|
entity_type="interview",entity_id=row.id,
|
||||||
|
to_value=row.interview_status or None,description=desc,commit=True,
|
||||||
|
)
|
||||||
return await self._serialize(row)
|
return await self._serialize(row)
|
||||||
|
|
||||||
async def update_interview(self,interview_id,payload):
|
async def update_interview(self,interview_id,payload,current_user=None):
|
||||||
fields={k:v for k,v in payload.items() if v is not None}
|
fields={k:v for k,v in payload.items() if v is not None}
|
||||||
if not fields:
|
if not fields:
|
||||||
raise HTTPException(status_code=400,detail="No fields to update")
|
raise HTTPException(status_code=400,detail="No fields to update")
|
||||||
|
before=await Interviews.get_interview_by_id(self.session,interview_id)
|
||||||
|
if not before:
|
||||||
|
raise HTTPException(status_code=404,detail="Interview not found")
|
||||||
|
old_status=before.interview_status or None
|
||||||
row=await Interviews.update_interview(self.session,interview_id,fields)
|
row=await Interviews.update_interview(self.session,interview_id,fields)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Interview not found")
|
raise HTTPException(status_code=404,detail="Interview not found")
|
||||||
|
when=row.interview_date or row.interview_time
|
||||||
|
desc=f"{row.interview_type or 'Interview'} on {when.isoformat() if when else '—'}"
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.INTERVIEW_UPDATED.value,
|
||||||
|
current_user=current_user,inbox_id=row.inbox_id,
|
||||||
|
entity_type="interview",entity_id=row.id,
|
||||||
|
from_value=old_status,to_value=row.interview_status or None,
|
||||||
|
description=desc,commit=True,
|
||||||
|
)
|
||||||
return await self._serialize(row)
|
return await self._serialize(row)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ from sqlalchemy.orm import selectinload
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from job.candidate.models import Notes
|
from job.candidate.models import Notes
|
||||||
|
from job.history.enums import HistoryEvent
|
||||||
|
from job.history.views import HistoryRecorder
|
||||||
from job.notes.serializers import serialize_note
|
from job.notes.serializers import serialize_note
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -48,15 +50,32 @@ class Note:
|
||||||
if not fields["user_id"]:
|
if not fields["user_id"]:
|
||||||
raise HTTPException(status_code=400,detail="user_id is required")
|
raise HTTPException(status_code=400,detail="user_id is required")
|
||||||
row=await Notes.insert_note(self.session,fields)
|
row=await Notes.insert_note(self.session,fields)
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.NOTE_CREATED.value,
|
||||||
|
current_user=current_user,user_id=row.user_id,
|
||||||
|
entity_type="note",entity_id=row.id,
|
||||||
|
to_value=(row.note or "")[:120],commit=True,
|
||||||
|
)
|
||||||
row=await self._load(row.id)
|
row=await self._load(row.id)
|
||||||
return serialize_note(row)
|
return serialize_note(row)
|
||||||
|
|
||||||
async def update_note(self,note_id,payload):
|
async def update_note(self,note_id,payload,current_user=None):
|
||||||
fields={k:v for k,v in payload.items() if v is not None and k in ("note",)}
|
fields={k:v for k,v in payload.items() if v is not None and k in ("note",)}
|
||||||
if not fields:
|
if not fields:
|
||||||
raise HTTPException(status_code=400,detail="No fields to update")
|
raise HTTPException(status_code=400,detail="No fields to update")
|
||||||
|
before=await self._load(note_id)
|
||||||
|
if not before:
|
||||||
|
raise HTTPException(status_code=404,detail="Note not found")
|
||||||
|
old_note=before.note or ""
|
||||||
row=await Notes.update_note(self.session,note_id,fields)
|
row=await Notes.update_note(self.session,note_id,fields)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Note not found")
|
raise HTTPException(status_code=404,detail="Note not found")
|
||||||
|
new_note=row.note or ""
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.NOTE_UPDATED.value,
|
||||||
|
current_user=current_user,user_id=row.user_id,
|
||||||
|
entity_type="note",entity_id=row.id,
|
||||||
|
from_value=old_note[:120],to_value=new_note[:120],commit=True,
|
||||||
|
)
|
||||||
row=await self._load(row.id)
|
row=await self._load(row.id)
|
||||||
return serialize_note(row)
|
return serialize_note(row)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from inbox.enums import Candidate_application_Status
|
from inbox.enums import Candidate_application_Status
|
||||||
from inbox.models import Inbox
|
from inbox.models import Inbox
|
||||||
from job.candidate.models import ApplicationStageTransitions, Manual_UPLOAD_CANDIDATE, _now
|
from job.candidate.models import ApplicationStageTransitions, Manual_UPLOAD_CANDIDATE, _now
|
||||||
|
from job.history.enums import HistoryEvent
|
||||||
|
from job.history.views import HistoryRecorder
|
||||||
from job.pipeline.serializers import serialize_pipeline_counts, serialize_stage_transition
|
from job.pipeline.serializers import serialize_pipeline_counts, serialize_stage_transition
|
||||||
from inbox.plugins import get_ats_score_for_manual_user, get_ats_score_for_user
|
from inbox.plugins import get_ats_score_for_manual_user, get_ats_score_for_user
|
||||||
|
|
||||||
|
|
@ -63,10 +65,10 @@ class Pipeline:
|
||||||
if isinstance(current_user,dict) and current_user.get("id"):
|
if isinstance(current_user,dict) and current_user.get("id"):
|
||||||
changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id"))
|
changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id"))
|
||||||
if inbox_id is not None:
|
if inbox_id is not None:
|
||||||
return await self._change_inbox_stage(inbox_id,stage,changed_by,change_reason)
|
return await self._change_inbox_stage(inbox_id,stage,changed_by,change_reason,current_user)
|
||||||
return await self._change_manual_stage(manual_upload_id,stage,changed_by,change_reason)
|
return await self._change_manual_stage(manual_upload_id,stage,changed_by,change_reason,current_user)
|
||||||
|
|
||||||
async def _change_inbox_stage(self,inbox_id,stage,changed_by,change_reason):
|
async def _change_inbox_stage(self,inbox_id,stage,changed_by,change_reason,current_user):
|
||||||
inbox=await Inbox.get_inbox_with_message(self.session,inbox_id)
|
inbox=await Inbox.get_inbox_with_message(self.session,inbox_id)
|
||||||
if not inbox:
|
if not inbox:
|
||||||
raise HTTPException(status_code=404,detail="Inbox not found")
|
raise HTTPException(status_code=404,detail="Inbox not found")
|
||||||
|
|
@ -91,6 +93,13 @@ class Pipeline:
|
||||||
},
|
},
|
||||||
commit=False,
|
commit=False,
|
||||||
)
|
)
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.STAGE_CHANGED.value,
|
||||||
|
current_user=current_user,inbox_id=inbox.id,user_id=inbox.user_id,
|
||||||
|
entity_type="application",entity_id=inbox.id,
|
||||||
|
from_value=from_stage,to_value=stage.value,
|
||||||
|
description=change_reason,commit=False,
|
||||||
|
)
|
||||||
message.application_status=stage
|
message.application_status=stage
|
||||||
self.session.add(message)
|
self.session.add(message)
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
|
|
@ -101,7 +110,7 @@ class Pipeline:
|
||||||
"transition":serialize_stage_transition(transition),
|
"transition":serialize_stage_transition(transition),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _change_manual_stage(self,manual_upload_id,stage,changed_by,change_reason):
|
async def _change_manual_stage(self,manual_upload_id,stage,changed_by,change_reason,current_user):
|
||||||
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,manual_upload_id)
|
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,manual_upload_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Manual upload candidate not found")
|
raise HTTPException(status_code=404,detail="Manual upload candidate not found")
|
||||||
|
|
@ -124,6 +133,13 @@ class Pipeline:
|
||||||
},
|
},
|
||||||
commit=False,
|
commit=False,
|
||||||
)
|
)
|
||||||
|
await HistoryRecorder(self.session).record(
|
||||||
|
HistoryEvent.STAGE_CHANGED.value,
|
||||||
|
current_user=current_user,manual_upload_candidate_id=row.id,user_id=row.user_id,
|
||||||
|
entity_type="application",entity_id=row.id,
|
||||||
|
from_value=from_stage,to_value=stage.value,
|
||||||
|
description=change_reason,commit=False,
|
||||||
|
)
|
||||||
row.status=stage.value
|
row.status=stage.value
|
||||||
row.updated_at=_now()
|
row.updated_at=_now()
|
||||||
self.session.add(row)
|
self.session.add(row)
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,9 @@ x-backend-env: &backend-env
|
||||||
# compose network on 5432, not the published host port.
|
# compose network on 5432, not the published host port.
|
||||||
DB_HOST: ${DB_HOST:-host.docker.internal}
|
DB_HOST: ${DB_HOST:-host.docker.internal}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
EMAIL_URL: http://host.docker.internal:5000
|
# Prefer root/.env EMAIL_URL (e.g. http://3.140.173.13:5000). Fall back to the
|
||||||
|
# host-gateway alias when the mail service runs on this machine's :5000.
|
||||||
|
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
|
||||||
BACKEND_URL: http://backend-api:8000
|
BACKEND_URL: http://backend-api:8000
|
||||||
|
|
||||||
# The one shared folder. Every process that decodes, scores or serves a CV reads and
|
# The one shared folder. Every process that decodes, scores or serves a CV reads and
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,16 @@ export function createActivity({ inboxId, type, status, description }) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit log for one candidate. UNLIKE the four child collections above, history is
|
||||||
|
* NOT bundled into the detail payload: it is append-only and unbounded, and the
|
||||||
|
* detail query refetches on every write in the modal. Fetched lazily when the
|
||||||
|
* History tab opens, paginated server-side.
|
||||||
|
*/
|
||||||
|
export function listHistory(userId, { limit = 200, offset = 0 } = {}) {
|
||||||
|
return request('/candidate/history/fetch', { params: { user_id: userId, limit, offset } })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticated attachment download. Never send a filesystem path — the server
|
* Authenticated attachment download. Never send a filesystem path — the server
|
||||||
* resolves by owning record + index. `inboxId` is the `inbox` table PK (int),
|
* resolves by owning record + index. `inboxId` is the `inbox` table PK (int),
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export const qk = {
|
||||||
all: () => ['candidates'],
|
all: () => ['candidates'],
|
||||||
list: (p = {}) => ['candidates', 'list', p],
|
list: (p = {}) => ['candidates', 'list', p],
|
||||||
detail: (id) => ['candidates', 'detail', id],
|
detail: (id) => ['candidates', 'detail', id],
|
||||||
|
history: (id, p = {}) => ['candidates', 'history', id, p],
|
||||||
},
|
},
|
||||||
// Board rows come from the same endpoint as qk.candidates.list but are cached
|
// Board rows come from the same endpoint as qk.candidates.list but are cached
|
||||||
// MAPPED (kanban cards, not the raw envelope), so they need their own key —
|
// MAPPED (kanban cards, not the raw envelope), so they need their own key —
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/* The 8-tab candidate profile modal, split out of Candidates.jsx — it was the
|
/* The 9-tab candidate profile modal, split out of Candidates.jsx — it was the
|
||||||
single largest block in js/candidates.js and deserves its own file.
|
single largest block in js/candidates.js and deserves its own file.
|
||||||
|
|
||||||
TWO DATA MODES, selected by whether the caller passes a `userId`:
|
TWO DATA MODES, selected by whether the caller passes a `userId`:
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
match verdict, documents, and the four child collections (interviews,
|
match verdict, documents, and the four child collections (interviews,
|
||||||
notes, activity, feedback). The write tabs POST to their own endpoints
|
notes, activity, feedback). The write tabs POST to their own endpoints
|
||||||
and invalidate this one query, so the whole modal repaints from a single
|
and invalidate this one query, so the whole modal repaints from a single
|
||||||
refetch.
|
refetch. History is fetched separately (GET /candidate/history/fetch)
|
||||||
|
when that tab opens — it is append-only and not part of the detail payload.
|
||||||
|
|
||||||
Live collections are NEVER padded with the seed's demo rows. An empty tab gets
|
Live collections are NEVER padded with the seed's demo rows. An empty tab gets
|
||||||
an empty state, because inventing three scorecards for a real applicant is
|
an empty state, because inventing three scorecards for a real applicant is
|
||||||
|
|
@ -34,7 +35,7 @@ import { friendlyAuthError } from '../lib/errors'
|
||||||
import * as candidatesApi from '../api/candidates'
|
import * as candidatesApi from '../api/candidates'
|
||||||
import { companies, fmtDate, moneyK, pick } from '../data/seed'
|
import { companies, fmtDate, moneyK, pick } from '../data/seed'
|
||||||
|
|
||||||
const TABS = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']
|
const TABS = ['Overview', 'Resume', 'Timeline', 'History', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']
|
||||||
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
||||||
const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
|
const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
|
||||||
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
|
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
|
||||||
|
|
@ -96,6 +97,7 @@ function useProfileWrite({ userId, mutationFn, success, onDone }) {
|
||||||
mutationFn,
|
mutationFn,
|
||||||
onSuccess: async (_data, vars) => {
|
onSuccess: async (_data, vars) => {
|
||||||
await qc.invalidateQueries({ queryKey: qk.candidates.detail(userId) })
|
await qc.invalidateQueries({ queryKey: qk.candidates.detail(userId) })
|
||||||
|
await qc.invalidateQueries({ queryKey: ['candidates', 'history', userId] })
|
||||||
toast(typeof success === 'function' ? success(vars) : success, 'success')
|
toast(typeof success === 'function' ? success(vars) : success, 'success')
|
||||||
onDone?.()
|
onDone?.()
|
||||||
},
|
},
|
||||||
|
|
@ -385,6 +387,14 @@ export default function CandidateProfile({
|
||||||
</div>
|
</div>
|
||||||
)))}
|
)))}
|
||||||
|
|
||||||
|
{tab === 'History' && (guard || (live ? (
|
||||||
|
<HistoryTab userId={c.userId} />
|
||||||
|
) : (
|
||||||
|
<EmptyState icon="clock" title="No history">
|
||||||
|
Audit history is recorded for live candidates only.
|
||||||
|
</EmptyState>
|
||||||
|
)))}
|
||||||
|
|
||||||
{tab === 'Interview' && (guard || (live ? (
|
{tab === 'Interview' && (guard || (live ? (
|
||||||
<InterviewTab userId={c.userId} inboxId={inboxId} rows={live.interviews ?? []} />
|
<InterviewTab userId={c.userId} inboxId={inboxId} rows={live.interviews ?? []} />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -619,6 +629,139 @@ function TimelineTab({ live }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HISTORY_ICON = {
|
||||||
|
'stage.changed': { icon: 'arrow-right', tone: 'i-indigo' },
|
||||||
|
'note.created': { icon: 'edit', tone: 'i-blue' },
|
||||||
|
'note.updated': { icon: 'edit', tone: 'i-blue' },
|
||||||
|
'feedback.created': { icon: 'award', tone: 'i-teal' },
|
||||||
|
'feedback.updated': { icon: 'award', tone: 'i-teal' },
|
||||||
|
'interview.created': { icon: 'calendar', tone: 'i-purple' },
|
||||||
|
'interview.updated': { icon: 'calendar', tone: 'i-purple' },
|
||||||
|
'calendar.created': { icon: 'send', tone: 'i-purple' },
|
||||||
|
'calendar.rescheduled': { icon: 'clock', tone: 'i-amber' },
|
||||||
|
'calendar.cancelled': { icon: 'x-circle', tone: 'i-red' },
|
||||||
|
'favorite.changed': { icon: 'star', tone: 'i-amber' },
|
||||||
|
'rating.changed': { icon: 'star', tone: 'i-amber' },
|
||||||
|
'candidate.created': { icon: 'user-plus', tone: 'i-green' },
|
||||||
|
'candidate.imported': { icon: 'upload', tone: 'i-green' },
|
||||||
|
'document.uploaded': { icon: 'paperclip', tone: 'i-red' },
|
||||||
|
'ats.scored': { icon: 'sparkles', tone: 'i-blue' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_TITLE = {
|
||||||
|
'stage.changed': 'Stage changed',
|
||||||
|
'note.created': 'Note added',
|
||||||
|
'note.updated': 'Note updated',
|
||||||
|
'feedback.created': 'Feedback submitted',
|
||||||
|
'feedback.updated': 'Feedback updated',
|
||||||
|
'interview.created': 'Interview scheduled',
|
||||||
|
'interview.updated': 'Interview updated',
|
||||||
|
'calendar.created': 'Calendar invite sent',
|
||||||
|
'calendar.rescheduled': 'Calendar rescheduled',
|
||||||
|
'calendar.cancelled': 'Calendar cancelled',
|
||||||
|
'favorite.changed': 'Favorite updated',
|
||||||
|
'rating.changed': 'Rating updated',
|
||||||
|
'candidate.created': 'Candidate created',
|
||||||
|
'candidate.imported': 'Candidate imported',
|
||||||
|
'document.uploaded': 'Document uploaded',
|
||||||
|
'ats.scored': 'ATS scored',
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyDayLabel(value) {
|
||||||
|
const d = value instanceof Date ? value : new Date(value ?? NaN)
|
||||||
|
if (Number.isNaN(d.getTime())) return 'Unknown'
|
||||||
|
const today = new Date()
|
||||||
|
const yday = new Date()
|
||||||
|
yday.setDate(today.getDate() - 1)
|
||||||
|
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||||
|
if (sameDay(d, today)) return 'Today'
|
||||||
|
if (sameDay(d, yday)) return 'Yesterday'
|
||||||
|
return fmtDate(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryTab({ userId }) {
|
||||||
|
const [limit, setLimit] = useState(200)
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: qk.candidates.history(userId, { limit }),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await candidatesApi.listHistory(userId, { limit })
|
||||||
|
return { rows: res?.data ?? [], total: res?.total ?? 0 }
|
||||||
|
},
|
||||||
|
enabled: Boolean(userId),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (q.isPending) {
|
||||||
|
return <EmptyState icon="refresh" title="Loading history…">Fetching the audit trail.</EmptyState>
|
||||||
|
}
|
||||||
|
if (q.isError) {
|
||||||
|
return (
|
||||||
|
<EmptyState icon="alert" title="Could not load history">
|
||||||
|
{friendlyAuthError(q.error, 'Please try again.')}
|
||||||
|
</EmptyState>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = q.data?.rows ?? []
|
||||||
|
const total = q.data?.total ?? 0
|
||||||
|
if (!rows.length) {
|
||||||
|
return <EmptyState icon="clock" title="No history yet">Actions on this candidate will appear here.</EmptyState>
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = []
|
||||||
|
for (const r of rows) {
|
||||||
|
const label = historyDayLabel(r.created_at)
|
||||||
|
const last = groups[groups.length - 1]
|
||||||
|
if (!last || last.label !== label) groups.push({ label, rows: [r] })
|
||||||
|
else last.rows.push(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div key={g.label} style={{ marginBottom: 18 }}>
|
||||||
|
<div style={LABEL}>{g.label}</div>
|
||||||
|
<div className="list-tight">
|
||||||
|
{g.rows.map((r) => <HistoryRow key={r.id} row={r} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{total > rows.length && (
|
||||||
|
<button className="btn btn-secondary btn-sm" style={{ marginTop: 8 }} onClick={() => setLimit((n) => n + 200)}>
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryRow({ row: r }) {
|
||||||
|
const look = HISTORY_ICON[r.event_type] || { icon: 'clock', tone: 'i-blue' }
|
||||||
|
const title = HISTORY_TITLE[r.event_type] || r.event_type
|
||||||
|
const change = [r.from_value, r.to_value].some((v) => v != null && v !== '')
|
||||||
|
? `${r.from_value ?? '—'} → ${r.to_value ?? '—'}`
|
||||||
|
: null
|
||||||
|
const actor = r.actor_name || (r.actor_id ? 'Unknown' : 'System')
|
||||||
|
const when = [fmtWhen(r.created_at), fmtClock(r.created_at)].filter(Boolean).join(' · ')
|
||||||
|
return (
|
||||||
|
<div className="list-row">
|
||||||
|
<span className={`kpi-icn ${look.tone}`} style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||||
|
<Icon name={look.icon} />
|
||||||
|
</span>
|
||||||
|
<div className="lr-main">
|
||||||
|
<div className="lr-title">{title}</div>
|
||||||
|
{change && <div className="lr-sub">{change}</div>}
|
||||||
|
{r.description && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{r.description}</div>}
|
||||||
|
<div className="lr-sub">{actor} · {when}</div>
|
||||||
|
</div>
|
||||||
|
<div className="lr-right">
|
||||||
|
{r.actor_name || r.actor_id
|
||||||
|
? <Avatar name={actor} />
|
||||||
|
: <Badge className="b-gray">System</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function InterviewTab({ userId, inboxId, rows }) {
|
function InterviewTab({ userId, inboxId, rows }) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [form, setForm] = useState({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] })
|
const [form, setForm] = useState({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] })
|
||||||
|
|
|
||||||
|
|
@ -744,6 +744,24 @@ export default function Inbox() {
|
||||||
markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate })
|
markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sync = useMutation({
|
||||||
|
mutationFn: () => inboxApi.syncMailbox(),
|
||||||
|
onSuccess: async (res) => {
|
||||||
|
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||||
|
// /email/fetch reports what the intake gate did with the page. The key is
|
||||||
|
// additive and absent when the gate is disabled, so fall back to the old
|
||||||
|
// message rather than rendering "undefined filtered out".
|
||||||
|
const t = res?.triage
|
||||||
|
toast(
|
||||||
|
t
|
||||||
|
? `Mailbox synced — ${t.ingested} imported, ${t.skipped} filtered out`
|
||||||
|
: 'Mailbox synced',
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
|
|
@ -755,12 +773,13 @@ export default function Inbox() {
|
||||||
<span className="integration-status"><span className="pulse" />Microsoft Graph API · Connected</span>
|
<span className="integration-status"><span className="pulse" />Microsoft Graph API · Connected</span>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
disabled={sync.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
toast('Syncing all sources…', 'info')
|
toast('Fetching from Outlook…', 'info')
|
||||||
setTimeout(() => toast('Inbox synced', 'success'), 900)
|
sync.mutate()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="refresh" /> Sync
|
<Icon name="refresh" /> {sync.isPending ? 'Syncing…' : 'Sync'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/import')}>
|
<button className="btn btn-primary" onClick={() => navigate('/import')}>
|
||||||
<Icon name="upload" /> Upload CVs
|
<Icon name="upload" /> Upload CVs
|
||||||
|
|
@ -1388,24 +1407,6 @@ function EmailTab({ query, toast }) {
|
||||||
setReadAll.mutate({ read, filter: {}, ids: emails.map((e) => e.id) })
|
setReadAll.mutate({ read, filter: {}, ids: emails.map((e) => e.id) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const sync = useMutation({
|
|
||||||
mutationFn: () => inboxApi.syncMailbox(),
|
|
||||||
onSuccess: async (res) => {
|
|
||||||
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
|
||||||
// /email/fetch now reports what the intake gate did with the page. The
|
|
||||||
// key is additive and absent when the gate is disabled, so fall back to
|
|
||||||
// the old message rather than rendering "undefined filtered out".
|
|
||||||
const t = res?.triage
|
|
||||||
toast(
|
|
||||||
t
|
|
||||||
? `Mailbox synced — ${t.ingested} imported, ${t.skipped} filtered out`
|
|
||||||
: 'Mailbox synced',
|
|
||||||
'success',
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const importMsg = useMutation({
|
const importMsg = useMutation({
|
||||||
mutationFn: (id) => inboxApi.setProcessingState(id, 'imported'),
|
mutationFn: (id) => inboxApi.setProcessingState(id, 'imported'),
|
||||||
onSuccess: (_d, id) => {
|
onSuccess: (_d, id) => {
|
||||||
|
|
@ -1441,16 +1442,8 @@ function EmailTab({ query, toast }) {
|
||||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<span className="integration-status"><span className="pulse" />Outlook · Microsoft Graph API</span>
|
<span className="integration-status"><span className="pulse" />Outlook · Microsoft Graph API</span>
|
||||||
<span className="text-muted text-sm">
|
<span className="text-muted text-sm">
|
||||||
{query.isPending ? 'Loading…' : query.isError ? 'Sync failed' : `${emails.length} messages · ${unread} unread`}
|
{query.isPending ? 'Loading…' : query.isError ? 'Failed to load' : `${emails.length} messages · ${unread} unread`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
style={{ marginLeft: 'auto' }}
|
|
||||||
disabled={sync.isPending}
|
|
||||||
onClick={() => { toast('Fetching from Outlook…', 'info'); sync.mutate() }}
|
|
||||||
>
|
|
||||||
<Icon name="refresh" /> {sync.isPending ? 'Syncing…' : 'Sync Mailbox'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="split inbox-split">
|
<div className="split inbox-split">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue