diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
deleted file mode 100644
index 7db53b6..0000000
--- a/.gitea/workflows/ci.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-name: CI
-
-# Same checks deploy-to-s3.yml gates on, run before a change reaches main.
-# main itself is excluded because the deploy workflow already runs them there;
-# without branches-ignore every merge would run the suite twice.
-on:
- push:
- branches-ignore:
- - main
- pull_request:
-
-jobs:
- checks:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@v3
-
- - name: Set up Node
- uses: actions/setup-node@v4
- with:
- node-version: '22'
-
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.11'
-
- - name: Install Python dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -e ".[dev]"
- pip install -r backend/requirements.txt
-
- - name: Run checks
- run: bash scripts/ci-checks.sh
diff --git a/.gitea/workflows/deploy-to-s3.yml b/.gitea/workflows/deploy-to-s3.yml
index c5883f0..76500c8 100644
--- a/.gitea/workflows/deploy-to-s3.yml
+++ b/.gitea/workflows/deploy-to-s3.yml
@@ -1,64 +1,25 @@
name: Deploy to S3
-# main only. Everything else is covered by ci.yml, which runs the same checks
-# without deploying.
on:
push:
- branches:
+ branches:
- main
jobs:
- # Nothing was verified before this existed: a frontend that failed to compile
- # would zip and ship exactly like a working one. `deploy` now needs this job,
- # so a red main does not reach the bucket.
- checks:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@v3
-
- # 22 to match frontend/Dockerfile, so CI resolves the same tree the
- # production image builds from.
- - name: Set up Node
- uses: actions/setup-node@v4
- with:
- node-version: '22'
-
- # 3.11 is the floor in pyproject.toml and the version the project's conda
- # env runs.
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.11'
-
- - name: Install Python dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -e ".[dev]"
- pip install -r backend/requirements.txt
-
- - name: Run checks
- run: bash scripts/ci-checks.sh
-
deploy:
- needs: checks
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- # frontend/node_modules is excluded, and that is safe because of what
- # happens to this object downstream. CodeDeploy pulls it, extracts to
- # /opt/codedeploy-extracted-5, copies the tree to
- # /home/ec2-user/utopia-ai-hr-ats-portal-deployment-group and runs
- # `docker compose --env-file ./backend/.env up -d --build`. The only Node
- # service is the frontend, whose image does `npm ci` from the lockfile,
- # and frontend/.dockerignore excludes node_modules/ from the build context
- # outright. So the committed tree was carried into every artifact and then
- # thrown away unread. It was 90 MB of a 33 MB compressed upload.
- #
- # node_modules is still tracked in git, which is the reason it was here at
- # all. Untracking it is a separate change and affects other branches.
+ - name: Configure AWS credentials
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }}
+ AWS_DEFAULT_REGION: us-east-1
+ run: |
+ echo "AWS credentials configured"
+
- name: Archive project
run: |
apt-get update -y
@@ -66,9 +27,8 @@ jobs:
zip -r utopia-ai-hr-ats-portal.zip . \
-x ".git/*" \
-x ".gitea/*" \
- -x ".gitignore" \
- -x "frontend/node_modules/*" \
- -x "*.DS_Store"
+ -x ".gitignore/*" \
+ -x "*.DS_Store"
- name: Install AWS CLI
run: |
@@ -77,18 +37,20 @@ jobs:
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
./aws/install
- aws --version
+ aws --version
- # The credentials live only on this step. There used to be a separate
- # "Configure AWS credentials" step above that set the same three variables
- # and then only echoed a message — env: is scoped to its own step, so
- # those values were discarded before anything could use them. It was doing
- # nothing, and it read as though credentials were set up globally.
- name: Upload files to S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
- echo "Uploading repo contents to S3..."
- aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip
+ echo "Uploading repo contents to S3..."
+ aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip
+
+
+
+
+
+
+
diff --git a/backend/job/assignment/views.py b/backend/job/assignment/views.py
index 743145d..99146fe 100644
--- a/backend/job/assignment/views.py
+++ b/backend/job/assignment/views.py
@@ -1,5 +1,6 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
+import logging
from job.assignment.models import ApplicationAssignments, JobAssignments
from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment
@@ -18,6 +19,8 @@ JOB_OWNER_COLUMN = {
"hiring_manager": "hiring_manager_id",
}
+logger = logging.getLogger(__name__)
+
class Assignment:
def __init__(self,session:AsyncSession):
@@ -102,7 +105,21 @@ class Assignment:
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
row=await self.record_job_owner(job_post_id,user_id,role,assigned_by)
column=JOB_OWNER_COLUMN[role]
- await JobPosts.update_job_post(self.session,job_post_id,{column:user_id})
+ updated=await JobPosts.update_job_post(self.session,job_post_id,{column:user_id})
+ if updated:
+ try:
+ from notifications.views import notify_job_assignment
+ label="hiring manager" if role=="hiring_manager" else "recruiter"
+ await notify_job_assignment(
+ self.session,updated,
+ role_label=label,
+ actor_id=assigned_by,
+ previous_ids=[
+ job.hiring_manager_id if role=="hiring_manager" else job.current_recruiter_id
+ ],
+ )
+ except Exception as exc:
+ logger.warning("notification insert skipped: %s", exc)
names=await Users.names_by_ids(
self.session,[row.user_id,row.assigned_by] if row else [],
)
diff --git a/backend/job/history/views.py b/backend/job/history/views.py
index bab10e5..37d10f9 100644
--- a/backend/job/history/views.py
+++ b/backend/job/history/views.py
@@ -111,7 +111,19 @@ class HistoryRecorder:
"actor_kind": actor_kind or "user",
"meta": meta,
}
- return await CandidateHistory.insert_event(self.session, fields, commit=commit)
+ row = await CandidateHistory.insert_event(self.session, fields, commit=commit)
+ try:
+ from notifications.views import notify_candidate_history
+ await notify_candidate_history(
+ self.session,
+ row,
+ inbox_id=inbox_id,
+ manual_upload_candidate_id=manual_upload_candidate_id,
+ commit=commit,
+ )
+ except Exception:
+ logger.exception("candidate history notification failed for %s", event_type)
+ return row
except Exception:
logger.exception("candidate history record failed for %s", event_type)
if commit:
diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py
index a8ca4fe..c2982ae 100644
--- a/backend/job/job_post/models.py
+++ b/backend/job/job_post/models.py
@@ -270,7 +270,8 @@ class JobPosts(SQLModel, table=True):
rows. Duplicate emails (case-insensitive) count once per job — the
furthest pipeline stage is kept. Flagged is_duplicate rows are skipped.
Rows with no email still count, each as themselves. Jobs with zero
- applicants still appear (LEFT JOIN).
+ applicants still appear (LEFT JOIN). `reapplied` is how many unique
+ applicants on the job also applied to at least one other job.
"""
from g_sheet.models import FormData
from inbox.models import Inbox_Messages
@@ -299,7 +300,7 @@ class JobPosts(SQLModel, table=True):
func.concat("noid:", cast(row_id, String)),
)
- inbox_q = (
+ inbox_base = (
select(
Inbox_Messages.assigned_job_post_id.label("job_post_id"),
dup_key(Inbox_Messages.message_from, Inbox_Messages.id).label("dup_key"),
@@ -316,7 +317,7 @@ class JobPosts(SQLModel, table=True):
func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.candidate_email), ""),
Users.email,
)
- manual_q = (
+ manual_base = (
select(
Manual_UPLOAD_CANDIDATE.job_post_id.label("job_post_id"),
dup_key(manual_email, Manual_UPLOAD_CANDIDATE.id).label("dup_key"),
@@ -332,7 +333,7 @@ class JobPosts(SQLModel, table=True):
(FormData.processing_state == "rejected", "REJECTED"),
else_="PENDING",
)
- form_q = (
+ form_base = (
select(
FormData.job_post_id.label("job_post_id"),
dup_key(FormData.candidate_email, FormData.id).label("dup_key"),
@@ -342,12 +343,23 @@ class JobPosts(SQLModel, table=True):
.where(FormData.manual_upload_candidate_id.is_(None))
.where(FormData.is_duplicate == False) # noqa: E712
)
+ # Unfiltered: an applicant on this page who also applied to a job
+ # not in the current page still counts as a reapplicant.
+ all_apps = union_all(inbox_base, manual_base, form_base).subquery("all_applications")
+ inbox_q, manual_q, form_q = inbox_base, manual_base, form_base
if job_uids:
- inbox_q = inbox_q.where(Inbox_Messages.assigned_job_post_id.in_(job_uids))
- manual_q = manual_q.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids))
- form_q = form_q.where(FormData.job_post_id.in_(job_uids))
+ inbox_q = inbox_base.where(Inbox_Messages.assigned_job_post_id.in_(job_uids))
+ manual_q = manual_base.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids))
+ form_q = form_base.where(FormData.job_post_id.in_(job_uids))
apps = union_all(inbox_q, manual_q, form_q).subquery("applications")
+ repeat_keys = (
+ select(all_apps.c.dup_key)
+ .where(~all_apps.c.dup_key.like("noid:%"))
+ .group_by(all_apps.c.dup_key)
+ .having(func.count(func.distinct(all_apps.c.job_post_id)) > 1)
+ .subquery("repeat_emails")
+ )
stage_rank = case(
(apps.c.stage == "HIRED", 9),
(apps.c.stage == "APPROVED", 8),
@@ -389,6 +401,16 @@ class JobPosts(SQLModel, table=True):
.group_by(unique_apps.c.job_post_id)
.subquery("job_stage_stats")
)
+ reapplied_stats = (
+ select(
+ unique_apps.c.job_post_id,
+ func.count().label("reapplied"),
+ )
+ .select_from(unique_apps)
+ .join(repeat_keys, repeat_keys.c.dup_key == unique_apps.c.dup_key)
+ .group_by(unique_apps.c.job_post_id)
+ .subquery("job_reapplied")
+ )
# Alias so this join does not collide with the Users join inside
# the manual-upload subquery above.
@@ -413,9 +435,11 @@ class JobPosts(SQLModel, table=True):
func.coalesce(stats.c.rejected, 0).label("rejected"),
func.coalesce(stats.c.approved, 0).label("approved"),
func.coalesce(stats.c.hired, 0).label("hired"),
+ func.coalesce(reapplied_stats.c.reapplied, 0).label("reapplied"),
)
.select_from(cls)
.outerjoin(stats, stats.c.job_post_id == cls.id)
+ .outerjoin(reapplied_stats, reapplied_stats.c.job_post_id == cls.id)
.outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id)
.where(cls.is_deleted == False) # noqa: E712
)
diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py
index cfd756d..5cf3ef2 100644
--- a/backend/job/job_post/serializers.py
+++ b/backend/job/job_post/serializers.py
@@ -107,6 +107,7 @@ def serialize_job_stats(row) -> dict:
"rejected": int(row["rejected"] or 0),
"approved": int(row["approved"] or 0),
"hired": int(row["hired"] or 0),
+ "reapplied": int(row.get("reapplied") or 0),
}
diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py
index bd28518..1965178 100644
--- a/backend/job/job_post/views.py
+++ b/backend/job/job_post/views.py
@@ -191,6 +191,12 @@ class JobPost:
if rec:
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by)
+ try:
+ from notifications.views import notify_job_created
+ await notify_job_created(self.session,row,actor_id=assigned_by)
+ except Exception as exc:
+ logger.warning("notification insert skipped: %s",exc)
+
# A new opening is the moment the CV Bank is worth reading. Ranking it
# here is what turns the bank from a pile someone has to remember into
# something that offers itself up. Fire-and-forget: the job is already
@@ -440,6 +446,22 @@ class JobPost:
await assignment.record_job_owner(
job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by,
)
+ if hm_changed or rec_changed:
+ try:
+ from notifications.views import notify_job_assignment
+ labels=[]
+ if hm_changed:
+ labels.append("hiring manager")
+ if rec_changed:
+ labels.append("recruiter")
+ await notify_job_assignment(
+ self.session,row,
+ role_label=" and ".join(labels),
+ actor_id=assigned_by,
+ previous_ids=[existing.hiring_manager_id,existing.current_recruiter_id],
+ )
+ except Exception as exc:
+ logger.warning("notification insert skipped: %s",exc)
return await self._job_row(row)
async def delete_job(self,job_post_id,current_user):
@@ -499,25 +521,22 @@ class JobPost:
)
status=parsed.value
actor=current_user.get("id") if isinstance(current_user,dict) else None
+ previous=None
+ existing=await JobPosts.get_job_post_by_id(self.session,job_post_id)
+ if existing:
+ previous=existing.requisition_status
row=await JobPosts.set_requisition_status(
self.session,job_post_id,status,changed_by=actor,
)
if not row:
raise HTTPException(status_code=404,detail="Job post not found")
- if status==RequisitionStatus.CLOSED.value:
+ if previous!=status:
try:
- from notifications.models import Notifications
- raw=row.current_recruiter_id or (current_user.get("id") if current_user else None)
- recipient=uuid.UUID(str(raw)) if raw else None
- if recipient:
- await Notifications.insert_notification(self.session,{
- "user_id":recipient,
- "kind":"approval",
- "title":"Requisition closed",
- "body":f"{row.title} was closed",
- "link_path":"/jobs",
- "job_post_id":row.id,
- })
+ from notifications.views import notify_job_status
+ await notify_job_status(
+ self.session,row,
+ from_status=previous,to_status=status,actor_id=actor,
+ )
except Exception as exc:
logger.warning("notification insert skipped: %s",exc)
return await self._job_row(row)
diff --git a/backend/notifications/models.py b/backend/notifications/models.py
index 9b66469..f5a7edb 100644
--- a/backend/notifications/models.py
+++ b/backend/notifications/models.py
@@ -175,6 +175,30 @@ class Notifications(SQLModel, table=True):
await session.commit()
return await cls.get_by_id(session, row.id)
+ @classmethod
+ async def insert_many(cls, session: AsyncSession, payloads, *, commit: bool = True):
+ """One row per payload. `commit=False` rides the caller's transaction."""
+ rows = []
+ for fields in payloads or []:
+ uid = cls._as_uuid(fields.get("user_id"))
+ if uid is None or not fields.get("kind") or not fields.get("title"):
+ continue
+ job_id = cls._as_uuid(fields.get("job_post_id")) if fields.get("job_post_id") else None
+ row = cls(
+ user_id=uid,
+ kind=fields["kind"],
+ title=fields["title"],
+ body=fields.get("body"),
+ link_path=fields.get("link_path"),
+ inbox_id=fields.get("inbox_id"),
+ job_post_id=job_id,
+ )
+ session.add(row)
+ rows.append(row)
+ if commit and rows:
+ await session.commit()
+ return rows
+
@classmethod
async def mark_read(cls, session: AsyncSession, record_id, *, user_id):
row = await cls.get_by_id(session, record_id, user_id=user_id)
diff --git a/backend/notifications/views.py b/backend/notifications/views.py
index eef3bf6..ad0ee4a 100644
--- a/backend/notifications/views.py
+++ b/backend/notifications/views.py
@@ -1,10 +1,11 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
import httpx
-
+import logging
import uuid
from notifications.models import EmailConfirmationTokens,Notifications
+from role.models import EnumRoles
from notifications.plugins import (
CONFIRM_TOKEN_RESEND_SECONDS,
CONFIRM_TOKEN_TTL_SECONDS,
@@ -26,6 +27,67 @@ from notifications.serializers import (
)
from users.models import Users
+logger = logging.getLogger(__name__)
+
+# Candidate-history event_type → in-app kind, title, and profile tab.
+_HISTORY_KIND = {
+ "stage.changed": "application",
+ "candidate.created": "application",
+ "candidate.imported": "application",
+ "interview.created": "interview",
+ "interview.updated": "interview",
+ "calendar.created": "interview",
+ "calendar.rescheduled": "interview",
+ "calendar.cancelled": "interview",
+ "ats.scored": "assessment",
+ "form.created": "approval",
+ "form.updated": "approval",
+ "note.created": "message",
+ "note.updated": "message",
+ "feedback.created": "message",
+ "feedback.updated": "message",
+}
+_HISTORY_TITLE = {
+ "stage.changed": "Stage changed",
+ "note.created": "Note added",
+ "note.updated": "Note updated",
+ "feedback.created": "Feedback added",
+ "feedback.updated": "Feedback updated",
+ "interview.created": "Interview scheduled",
+ "interview.updated": "Interview updated",
+ "calendar.created": "Calendar event created",
+ "calendar.rescheduled": "Interview rescheduled",
+ "calendar.cancelled": "Interview cancelled",
+ "favorite.changed": "Favorite updated",
+ "rating.changed": "Rating updated",
+ "candidate.created": "Candidate added",
+ "candidate.imported": "Candidate imported",
+ "document.uploaded": "Document uploaded",
+ "ats.scored": "ATS score ready",
+ "form.created": "Form submitted",
+ "form.updated": "Form updated",
+}
+_HISTORY_TAB = {
+ "stage.changed": "History",
+ "interview.created": "Interview",
+ "interview.updated": "Interview",
+ "calendar.created": "Interview",
+ "calendar.rescheduled": "Interview",
+ "calendar.cancelled": "Interview",
+ "note.created": "Notes",
+ "note.updated": "Notes",
+ "feedback.created": "Activity",
+ "feedback.updated": "Activity",
+ "form.created": "Forms",
+ "form.updated": "Forms",
+ "ats.scored": "Resume",
+ "document.uploaded": "History",
+ "candidate.created": "History",
+ "candidate.imported": "History",
+ "favorite.changed": "History",
+ "rating.changed": "History",
+}
+
class Confirmation:
def __init__(self,session:AsyncSession):
@@ -118,6 +180,315 @@ def _user_id(current_user):
return uid
+def _humanize_event(event_type):
+ text = str(event_type or "").replace(".", " ").replace("_", " ").strip()
+ return text[:1].upper() + text[1:] if text else "Update"
+
+
+def _candidate_link(user_id, tab="History"):
+ path = f"/candidate/{user_id}"
+ if tab:
+ return f"{path}?tab={tab}"
+ return path
+
+
+def _job_link(job_post_id, tab=None):
+ path = f"/jobs?job={job_post_id}"
+ if tab:
+ return f"{path}&tab={tab}"
+ return path
+
+
+async def system_admin_ids(session):
+ return await Users.ids_by_role_names(session, [EnumRoles.SYSTEM_ADMINISTRATOR.value])
+
+
+async def job_recruiter_ids(session, job):
+ """Recruiters currently linked to the job post.
+
+ Uses the live pointer (current_recruiter_id) and open job_assignments
+ rows with assignment_role=primary_recruiter.
+ """
+ ids = set()
+ if job is None:
+ return ids
+ uid = _as_uuid(getattr(job, "current_recruiter_id", None))
+ if uid is not None:
+ ids.add(uid)
+ from job.assignment.models import JobAssignments
+ rows = await JobAssignments.fetch_by_job(
+ session, job.id, current_only=True, assignment_role="primary_recruiter",
+ )
+ for row in rows:
+ rid = _as_uuid(row.user_id)
+ if rid is not None:
+ ids.add(rid)
+ return ids
+
+
+async def job_stakeholder_ids(session, jobs, *, extra_ids=None, include_admins=True):
+ """Recruiter, hiring manager, created_by, plus system admins.
+
+ `jobs` may be one row or an iterable. Extra ids cover people who just
+ left an assignment so they still see the history entry.
+ """
+ rows = jobs if isinstance(jobs, (list, tuple, set)) else [jobs]
+ ids = set()
+ for job in rows:
+ if job is None:
+ continue
+ ids.update(await job_recruiter_ids(session, job))
+ for raw in (job.hiring_manager_id, job.created_by):
+ uid = _as_uuid(raw)
+ if uid is not None:
+ ids.add(uid)
+ for raw in extra_ids or []:
+ uid = _as_uuid(raw)
+ if uid is not None:
+ ids.add(uid)
+ if include_admins:
+ ids.update(await system_admin_ids(session))
+ return ids
+
+
+async def notify_users(
+ session,
+ user_ids,
+ *,
+ kind,
+ title,
+ body=None,
+ link_path=None,
+ inbox_id=None,
+ job_post_id=None,
+ exclude_ids=None,
+ commit=True,
+):
+ """Fan-out one in-app row per recipient. Failures never raise."""
+ try:
+ exclude = {_as_uuid(x) for x in (exclude_ids or [])}
+ exclude.discard(None)
+ seen = set()
+ payloads = []
+ for raw in user_ids or []:
+ uid = _as_uuid(raw)
+ if uid is None or uid in exclude or uid in seen:
+ continue
+ seen.add(uid)
+ payloads.append({
+ "user_id": uid,
+ "kind": kind,
+ "title": title,
+ "body": body,
+ "link_path": link_path,
+ "inbox_id": inbox_id,
+ "job_post_id": job_post_id,
+ })
+ if not payloads:
+ return []
+ return await Notifications.insert_many(session, payloads, commit=commit)
+ except Exception as exc:
+ logger.warning("notification insert skipped: %s", exc)
+ return []
+
+
+async def notify_job_stakeholders(
+ session,
+ job,
+ *,
+ kind,
+ title,
+ body=None,
+ link_path=None,
+ extra_ids=None,
+ exclude_ids=None,
+ commit=True,
+):
+ if job is None:
+ return []
+ recipients = await job_stakeholder_ids(session, job, extra_ids=extra_ids)
+ return await notify_users(
+ session,
+ recipients,
+ kind=kind,
+ title=title,
+ body=body,
+ link_path=link_path or _job_link(job.id),
+ job_post_id=job.id,
+ exclude_ids=exclude_ids,
+ commit=commit,
+ )
+
+
+async def notify_job_created(session, job, *, actor_id=None):
+ """New requisition: recruiter, hiring manager, created_by, system admins.
+
+ created_by is included even when they are the actor — that is who asked.
+ """
+ if job is None:
+ return []
+ title = (job.title or "A job post").strip() or "A job post"
+ return await notify_job_stakeholders(
+ session,
+ job,
+ kind="approval",
+ title="New job post",
+ body=f"{title} was created",
+ link_path=_job_link(job.id),
+ exclude_ids=None,
+ commit=True,
+ )
+
+
+async def notify_job_status(session, job, *, from_status, to_status, actor_id=None):
+ """Status history: assigned recruiter, created_by, and every system admin."""
+ if job is None:
+ return []
+ title = (job.title or "A job post").strip() or "A job post"
+ if from_status:
+ body = f"{title} moved from {from_status} to {to_status}"
+ heading = "Requisition closed" if to_status == "closed" else "Requisition updated"
+ else:
+ body = f"{title} is now {to_status}"
+ heading = "Requisition updated"
+ recipients = await job_recruiter_ids(session, job)
+ created = _as_uuid(job.created_by)
+ if created is not None:
+ recipients.add(created)
+ recipients.update(await system_admin_ids(session))
+ return await notify_users(
+ session,
+ recipients,
+ kind="approval",
+ title=heading,
+ body=body,
+ link_path=_job_link(job.id, tab="history"),
+ job_post_id=job.id,
+ exclude_ids=None,
+ commit=True,
+ )
+
+
+async def notify_job_assignment(session, job, *, role_label, actor_id=None, previous_ids=None):
+ if job is None:
+ return []
+ title = (job.title or "A job post").strip() or "A job post"
+ return await notify_job_stakeholders(
+ session,
+ job,
+ kind="approval",
+ title="Job assignment updated",
+ body=f"{title}: {role_label} changed",
+ link_path=_job_link(job.id, tab="history"),
+ extra_ids=previous_ids,
+ exclude_ids=[actor_id] if actor_id else None,
+ commit=True,
+ )
+
+
+async def _job_ids_for_candidate_event(session, *, user_id, inbox_id, manual_upload_candidate_id):
+ """Resolve job posts for a history row without importing candidate views."""
+ from inbox.models import Inbox
+ from job.candidate.models import Manual_UPLOAD_CANDIDATE
+
+ ids = set()
+ if inbox_id is not None:
+ link = await Inbox.get_inbox_with_message(session, inbox_id)
+ msg = getattr(link, "messages", None) if link is not None else None
+ jid = getattr(msg, "assigned_job_post_id", None) if msg is not None else None
+ if jid:
+ ids.add(jid)
+ if manual_upload_candidate_id is not None:
+ manual = await Manual_UPLOAD_CANDIDATE.get_by_id(session, manual_upload_candidate_id)
+ if manual and manual.job_post_id:
+ ids.add(manual.job_post_id)
+ scoped = inbox_id is not None or manual_upload_candidate_id is not None
+ if ids or user_id is None or scoped:
+ return ids
+ rows = await Inbox.get_candidate_profile(session=session, user_id=user_id, limit=1000, offset=0)
+ records = rows if isinstance(rows, list) else ([rows] if rows else [])
+ for rec in records:
+ msg = getattr(rec, "messages", None)
+ jid = getattr(msg, "assigned_job_post_id", None) if msg is not None else None
+ if jid:
+ ids.add(jid)
+ manual = await Manual_UPLOAD_CANDIDATE.get_by_user_id(session, user_id)
+ if manual and manual.job_post_id:
+ ids.add(manual.job_post_id)
+ return ids
+
+
+async def notify_candidate_history(
+ session,
+ row,
+ *,
+ inbox_id=None,
+ manual_upload_candidate_id=None,
+ commit=True,
+):
+ """One in-app row per job stakeholder for a candidate_history write."""
+ if row is None:
+ return []
+ try:
+ from job.job_post.models import JobPosts
+
+ event_type = row.event_type
+ names = await Users.names_by_ids(session, [row.user_id])
+ candidate_name = names.get(str(row.user_id)) or "A candidate"
+ heading = _HISTORY_TITLE.get(event_type) or _humanize_event(event_type)
+ kind = _HISTORY_KIND.get(event_type, "system")
+ tab = _HISTORY_TAB.get(event_type, "History")
+ if row.description:
+ detail = row.description
+ elif row.from_value and row.to_value:
+ detail = f"{row.from_value} → {row.to_value}"
+ elif row.to_value:
+ detail = str(row.to_value)
+ else:
+ detail = heading
+ body = f"{candidate_name}: {detail}"
+ link_path = _candidate_link(row.user_id, tab=tab)
+
+ job_ids = await _job_ids_for_candidate_event(
+ session,
+ user_id=row.user_id,
+ inbox_id=inbox_id if inbox_id is not None else row.inbox_id,
+ manual_upload_candidate_id=(
+ manual_upload_candidate_id
+ if manual_upload_candidate_id is not None
+ else row.manual_upload_candidate_id
+ ),
+ )
+ jobs = []
+ if job_ids:
+ jobs = await JobPosts.get_by_ids(session, list(job_ids), active_only=False)
+ if jobs:
+ recipients = await job_stakeholder_ids(session, jobs, include_admins=True)
+ else:
+ recipients = set(await system_admin_ids(session))
+
+ exclude = [row.user_id]
+ if row.actor_id:
+ exclude.append(row.actor_id)
+ job_post_id = jobs[0].id if jobs else None
+ inbox_value = row.inbox_id if row.inbox_id is not None else inbox_id
+ return await notify_users(
+ session,
+ recipients,
+ kind=kind,
+ title=heading,
+ body=body,
+ link_path=link_path,
+ inbox_id=inbox_value,
+ job_post_id=job_post_id,
+ exclude_ids=exclude,
+ commit=commit,
+ )
+ except Exception as exc:
+ logger.warning("notification insert skipped: %s", exc)
+ return []
+
+
class Notification:
def __init__(self,session:AsyncSession):
self.session=session
diff --git a/backend/users/models.py b/backend/users/models.py
index 34d9082..323f394 100644
--- a/backend/users/models.py
+++ b/backend/users/models.py
@@ -135,6 +135,23 @@ class Users(SQLModel, table=True):
result = await session.execute(statement)
return list(result.scalars().all())
+ @classmethod
+ async def ids_by_role_names(cls, session: AsyncSession, role_names):
+ """Non-deleted user ids whose Roles.role_name is in `role_names`."""
+ names = sorted({(n or "").strip() for n in (role_names or []) if (n or "").strip()})
+ if not names:
+ return []
+ lowers = [n.lower() for n in names]
+ result = await session.execute(
+ select(cls.id)
+ .join(Roles, cls.role_id == Roles.id)
+ .where(
+ func.lower(Roles.role_name).in_(lowers),
+ cls.is_deleted == False, # noqa: E712
+ )
+ )
+ return [row[0] for row in result.all()]
+
@classmethod
async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
"""Resolve {user_id: name} in a single query.
diff --git a/frontend/src/api/jobStats.js b/frontend/src/api/jobStats.js
index cad5c6b..04f1d54 100644
--- a/frontend/src/api/jobStats.js
+++ b/frontend/src/api/jobStats.js
@@ -60,5 +60,6 @@ export function toJobStatsView(row) {
rejected: Number(row.rejected) || 0,
approved: Number(row.approved) || 0,
hired: Number(row.hired) || 0,
+ reapplied: Number(row.reapplied) || 0,
}
}
diff --git a/frontend/src/app/Topbar.jsx b/frontend/src/app/Topbar.jsx
index 89807cf..27bc55b 100644
--- a/frontend/src/app/Topbar.jsx
+++ b/frontend/src/app/Topbar.jsx
@@ -35,7 +35,12 @@ export default function Topbar({ onOpenNav, searchRef }) {
const navigate = useNavigate()
const qc = useQueryClient()
- const notifQuery = useQuery({ queryKey: qk.notifications.list({ top: 6 }), queryFn: fetchNotifications })
+ const notifQuery = useQuery({
+ queryKey: qk.notifications.list({ top: 6 }),
+ queryFn: fetchNotifications,
+ staleTime: 0,
+ refetchInterval: 15_000,
+ })
const notifications = notifQuery.data?.items ?? []
const unread = notifQuery.data?.unread ?? 0
diff --git a/frontend/src/app/useShell.js b/frontend/src/app/useShell.js
index ba517d5..976f09e 100644
--- a/frontend/src/app/useShell.js
+++ b/frontend/src/app/useShell.js
@@ -149,6 +149,8 @@ export function useBadges() {
return 0
}
},
+ staleTime: 0,
+ refetchInterval: 15_000,
})
return {
diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx
index 42eaa32..6c8357d 100644
--- a/frontend/src/screens/CandidateProfile.jsx
+++ b/frontend/src/screens/CandidateProfile.jsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
+import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
@@ -28,6 +29,12 @@ const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', '
// Forward progression for the live Advance button. Rejected has no next stage.
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
+
+function tabFromSearch(tabParam, visibleTabs, fallback) {
+ if (!tabParam) return fallback
+ const wanted = String(tabParam).trim()
+ return visibleTabs.find((t) => t.toLowerCase() === wanted.toLowerCase()) || fallback
+}
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']
@@ -107,7 +114,12 @@ export default function CandidateProfile({
const { can, user } = useAuth()
const isManager = isHiringManager(user)
const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS
- const [tab, setTab] = useState(isManager ? 'Forms' : 'Overview')
+ const [searchParams] = useSearchParams()
+ const [tab, setTab] = useState(() => tabFromSearch(
+ variant === 'page' ? searchParams.get('tab') : null,
+ visibleTabs,
+ isManager ? 'Forms' : 'Overview',
+ ))
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const isLive = Boolean(c.userId)
diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx
index 44fea39..fad60bb 100644
--- a/frontend/src/screens/Jobs.jsx
+++ b/frontend/src/screens/Jobs.jsx
@@ -8,7 +8,7 @@
============================================================ */
import { useEffect, useMemo, useRef, useState } from 'react'
-import { useLocation, useNavigate } from 'react-router-dom'
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import AiFieldAssist from '../ui/AiFieldAssist'
@@ -83,6 +83,7 @@ export default function Jobs() {
const { can } = useAuth()
const navigate = useNavigate()
const location = useLocation()
+ const [searchParams, setSearchParams] = useSearchParams()
const qc = useQueryClient()
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
@@ -106,26 +107,43 @@ export default function Jobs() {
const [type, setType] = useState('')
const [viewing, setViewing] = useState(null)
+ const [viewingTab, setViewingTab] = useState('details')
const [editing, setEditing] = useState(null)
const [creating, setCreating] = useState(false)
const canEdit = can('jobs.edit')
const canDelete = can('jobs.delete')
- // Deep-link intents from global search, the dashboard and the manager portal.
- // Consume once and replace history state: jobs refetch after a status PATCH
- // used to replay openCreate and pop the create modal over the detail view.
+ // Deep-link intents from notifications, global search, the dashboard and the
+ // manager portal. Consume once and replace history: jobs refetch after a
+ // status PATCH used to replay openCreate and pop the create modal over the
+ // detail view. `/jobs?job=` / `?tab=history` is the notification target.
useEffect(() => {
const st = location.state
- if (!st?.openCreate && !st?.openJob) return
- if (st.openCreate) setCreating(true)
- if (st.openJob) {
- const job = jobs.find((j) => j.id === st.openJob)
- if (job) setViewing(job)
- else if (!jobsQuery.isSuccess) return
+ const jobId = searchParams.get('job') || st?.openJob
+ const tab = String(searchParams.get('tab') || '').toLowerCase()
+ if (!st?.openCreate && !jobId) return
+ if (st?.openCreate) setCreating(true)
+ if (jobId) {
+ const job = jobs.find((j) => j.id === jobId)
+ if (job) {
+ setViewing(job)
+ setViewingTab(tab === 'history' ? 'history' : 'details')
+ } else if (!jobsQuery.isSuccess) return
}
- navigate('.', { replace: true, state: null })
- }, [location.state, jobs, jobsQuery.isSuccess, navigate])
+ const next = new URLSearchParams(searchParams)
+ let queryChanged = false
+ if (next.has('job')) {
+ next.delete('job')
+ queryChanged = true
+ }
+ if (next.has('tab')) {
+ next.delete('tab')
+ queryChanged = true
+ }
+ if (queryChanged) setSearchParams(next, { replace: true })
+ if (st?.openJob || st?.openCreate) navigate('.', { replace: true, state: null })
+ }, [location.state, searchParams, jobs, jobsQuery.isSuccess, navigate, setSearchParams])
useEffect(() => {
if (!viewing) return
@@ -151,6 +169,7 @@ export default function Jobs() {
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
+ qc.invalidateQueries({ queryKey: qk.notifications.all() })
setCreating(false)
if (res?.imageFailed) toast('Job created, but the cover image failed to upload.', 'error')
else toast('Job created', 'success')
@@ -184,6 +203,7 @@ export default function Jobs() {
mutationFn: ({ id, status: next }) => jobsApi.setStatus(id, next),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
+ qc.invalidateQueries({ queryKey: qk.notifications.all() })
toast(`Status set to ${vars.status}`, 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
@@ -368,12 +388,14 @@ export default function Jobs() {
{viewing && (