Merge pull request 'SQS_BROKER' (#73) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 39s Details

Reviewed-on: #73
pull/74/head
ahmed.mujtaba 2026-09-07 09:05:41 +00:00
commit 3086f3fca7
17 changed files with 610 additions and 139 deletions

View File

@ -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

View File

@ -1,64 +1,25 @@
name: Deploy to S3 name: Deploy to S3
# main only. Everything else is covered by ci.yml, which runs the same checks
# without deploying.
on: on:
push: push:
branches: branches:
- main - main
jobs: 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: deploy:
needs: checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
# frontend/node_modules is excluded, and that is safe because of what - name: Configure AWS credentials
# happens to this object downstream. CodeDeploy pulls it, extracts to env:
# /opt/codedeploy-extracted-5, copies the tree to AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }}
# /home/ec2-user/utopia-ai-hr-ats-portal-deployment-group and runs AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }}
# `docker compose --env-file ./backend/.env up -d --build`. The only Node AWS_DEFAULT_REGION: us-east-1
# service is the frontend, whose image does `npm ci` from the lockfile, run: |
# and frontend/.dockerignore excludes node_modules/ from the build context echo "AWS credentials configured"
# 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: Archive project - name: Archive project
run: | run: |
apt-get update -y apt-get update -y
@ -66,9 +27,8 @@ jobs:
zip -r utopia-ai-hr-ats-portal.zip . \ zip -r utopia-ai-hr-ats-portal.zip . \
-x ".git/*" \ -x ".git/*" \
-x ".gitea/*" \ -x ".gitea/*" \
-x ".gitignore" \ -x ".gitignore/*" \
-x "frontend/node_modules/*" \ -x "*.DS_Store"
-x "*.DS_Store"
- name: Install AWS CLI - name: Install AWS CLI
run: | run: |
@ -77,18 +37,20 @@ jobs:
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip unzip -q awscliv2.zip
./aws/install ./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 - name: Upload files to S3
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
run: | run: |
echo "Uploading repo contents to S3..." 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 aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip

View File

@ -1,5 +1,6 @@
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import logging
from job.assignment.models import ApplicationAssignments, JobAssignments from job.assignment.models import ApplicationAssignments, JobAssignments
from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment
@ -18,6 +19,8 @@ JOB_OWNER_COLUMN = {
"hiring_manager": "hiring_manager_id", "hiring_manager": "hiring_manager_id",
} }
logger = logging.getLogger(__name__)
class Assignment: class Assignment:
def __init__(self,session:AsyncSession): def __init__(self,session:AsyncSession):
@ -102,7 +105,21 @@ class Assignment:
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None 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) row=await self.record_job_owner(job_post_id,user_id,role,assigned_by)
column=JOB_OWNER_COLUMN[role] 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( names=await Users.names_by_ids(
self.session,[row.user_id,row.assigned_by] if row else [], self.session,[row.user_id,row.assigned_by] if row else [],
) )

View File

@ -111,7 +111,19 @@ class HistoryRecorder:
"actor_kind": actor_kind or "user", "actor_kind": actor_kind or "user",
"meta": meta, "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: except Exception:
logger.exception("candidate history record failed for %s", event_type) logger.exception("candidate history record failed for %s", event_type)
if commit: if commit:

View File

@ -270,7 +270,8 @@ class JobPosts(SQLModel, table=True):
rows. Duplicate emails (case-insensitive) count once per job the rows. Duplicate emails (case-insensitive) count once per job the
furthest pipeline stage is kept. Flagged is_duplicate rows are skipped. furthest pipeline stage is kept. Flagged is_duplicate rows are skipped.
Rows with no email still count, each as themselves. Jobs with zero 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 g_sheet.models import FormData
from inbox.models import Inbox_Messages from inbox.models import Inbox_Messages
@ -299,7 +300,7 @@ class JobPosts(SQLModel, table=True):
func.concat("noid:", cast(row_id, String)), func.concat("noid:", cast(row_id, String)),
) )
inbox_q = ( inbox_base = (
select( select(
Inbox_Messages.assigned_job_post_id.label("job_post_id"), Inbox_Messages.assigned_job_post_id.label("job_post_id"),
dup_key(Inbox_Messages.message_from, Inbox_Messages.id).label("dup_key"), 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), ""), func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.candidate_email), ""),
Users.email, Users.email,
) )
manual_q = ( manual_base = (
select( select(
Manual_UPLOAD_CANDIDATE.job_post_id.label("job_post_id"), Manual_UPLOAD_CANDIDATE.job_post_id.label("job_post_id"),
dup_key(manual_email, Manual_UPLOAD_CANDIDATE.id).label("dup_key"), 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"), (FormData.processing_state == "rejected", "REJECTED"),
else_="PENDING", else_="PENDING",
) )
form_q = ( form_base = (
select( select(
FormData.job_post_id.label("job_post_id"), FormData.job_post_id.label("job_post_id"),
dup_key(FormData.candidate_email, FormData.id).label("dup_key"), 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.manual_upload_candidate_id.is_(None))
.where(FormData.is_duplicate == False) # noqa: E712 .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: if job_uids:
inbox_q = inbox_q.where(Inbox_Messages.assigned_job_post_id.in_(job_uids)) inbox_q = inbox_base.where(Inbox_Messages.assigned_job_post_id.in_(job_uids))
manual_q = manual_q.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids)) manual_q = manual_base.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids))
form_q = form_q.where(FormData.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") 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( stage_rank = case(
(apps.c.stage == "HIRED", 9), (apps.c.stage == "HIRED", 9),
(apps.c.stage == "APPROVED", 8), (apps.c.stage == "APPROVED", 8),
@ -389,6 +401,16 @@ class JobPosts(SQLModel, table=True):
.group_by(unique_apps.c.job_post_id) .group_by(unique_apps.c.job_post_id)
.subquery("job_stage_stats") .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 # Alias so this join does not collide with the Users join inside
# the manual-upload subquery above. # 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.rejected, 0).label("rejected"),
func.coalesce(stats.c.approved, 0).label("approved"), func.coalesce(stats.c.approved, 0).label("approved"),
func.coalesce(stats.c.hired, 0).label("hired"), func.coalesce(stats.c.hired, 0).label("hired"),
func.coalesce(reapplied_stats.c.reapplied, 0).label("reapplied"),
) )
.select_from(cls) .select_from(cls)
.outerjoin(stats, stats.c.job_post_id == cls.id) .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) .outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id)
.where(cls.is_deleted == False) # noqa: E712 .where(cls.is_deleted == False) # noqa: E712
) )

View File

@ -107,6 +107,7 @@ def serialize_job_stats(row) -> dict:
"rejected": int(row["rejected"] or 0), "rejected": int(row["rejected"] or 0),
"approved": int(row["approved"] or 0), "approved": int(row["approved"] or 0),
"hired": int(row["hired"] or 0), "hired": int(row["hired"] or 0),
"reapplied": int(row.get("reapplied") or 0),
} }

View File

@ -191,6 +191,12 @@ class JobPost:
if rec: if rec:
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by) 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 # 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 # 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 # something that offers itself up. Fire-and-forget: the job is already
@ -440,6 +446,22 @@ class JobPost:
await assignment.record_job_owner( await assignment.record_job_owner(
job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by, 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) return await self._job_row(row)
async def delete_job(self,job_post_id,current_user): async def delete_job(self,job_post_id,current_user):
@ -499,25 +521,22 @@ class JobPost:
) )
status=parsed.value status=parsed.value
actor=current_user.get("id") if isinstance(current_user,dict) else None 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( row=await JobPosts.set_requisition_status(
self.session,job_post_id,status,changed_by=actor, self.session,job_post_id,status,changed_by=actor,
) )
if not row: if not row:
raise HTTPException(status_code=404,detail="Job post not found") raise HTTPException(status_code=404,detail="Job post not found")
if status==RequisitionStatus.CLOSED.value: if previous!=status:
try: try:
from notifications.models import Notifications from notifications.views import notify_job_status
raw=row.current_recruiter_id or (current_user.get("id") if current_user else None) await notify_job_status(
recipient=uuid.UUID(str(raw)) if raw else None self.session,row,
if recipient: from_status=previous,to_status=status,actor_id=actor,
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,
})
except Exception as exc: except Exception as exc:
logger.warning("notification insert skipped: %s",exc) logger.warning("notification insert skipped: %s",exc)
return await self._job_row(row) return await self._job_row(row)

View File

@ -175,6 +175,30 @@ class Notifications(SQLModel, table=True):
await session.commit() await session.commit()
return await cls.get_by_id(session, row.id) 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 @classmethod
async def mark_read(cls, session: AsyncSession, record_id, *, user_id): async def mark_read(cls, session: AsyncSession, record_id, *, user_id):
row = await cls.get_by_id(session, record_id, user_id=user_id) row = await cls.get_by_id(session, record_id, user_id=user_id)

View File

@ -1,10 +1,11 @@
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import httpx import httpx
import logging
import uuid import uuid
from notifications.models import EmailConfirmationTokens,Notifications from notifications.models import EmailConfirmationTokens,Notifications
from role.models import EnumRoles
from notifications.plugins import ( from notifications.plugins import (
CONFIRM_TOKEN_RESEND_SECONDS, CONFIRM_TOKEN_RESEND_SECONDS,
CONFIRM_TOKEN_TTL_SECONDS, CONFIRM_TOKEN_TTL_SECONDS,
@ -26,6 +27,67 @@ from notifications.serializers import (
) )
from users.models import Users 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: class Confirmation:
def __init__(self,session:AsyncSession): def __init__(self,session:AsyncSession):
@ -118,6 +180,315 @@ def _user_id(current_user):
return uid 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: class Notification:
def __init__(self,session:AsyncSession): def __init__(self,session:AsyncSession):
self.session=session self.session=session

View File

@ -135,6 +135,23 @@ class Users(SQLModel, table=True):
result = await session.execute(statement) result = await session.execute(statement)
return list(result.scalars().all()) 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 @classmethod
async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]: async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
"""Resolve {user_id: name} in a single query. """Resolve {user_id: name} in a single query.

View File

@ -60,5 +60,6 @@ export function toJobStatsView(row) {
rejected: Number(row.rejected) || 0, rejected: Number(row.rejected) || 0,
approved: Number(row.approved) || 0, approved: Number(row.approved) || 0,
hired: Number(row.hired) || 0, hired: Number(row.hired) || 0,
reapplied: Number(row.reapplied) || 0,
} }
} }

View File

@ -35,7 +35,12 @@ export default function Topbar({ onOpenNav, searchRef }) {
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() 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 notifications = notifQuery.data?.items ?? []
const unread = notifQuery.data?.unread ?? 0 const unread = notifQuery.data?.unread ?? 0

View File

@ -149,6 +149,8 @@ export function useBadges() {
return 0 return 0
} }
}, },
staleTime: 0,
refetchInterval: 15_000,
}) })
return { return {

View File

@ -1,5 +1,6 @@
 
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
@ -28,6 +29,12 @@ const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', '
// Forward progression for the live Advance button. Rejected has no next stage. // Forward progression for the live Advance button. Rejected has no next stage.
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired'] const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
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 }
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_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show'] const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note'] const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']
@ -107,7 +114,12 @@ export default function CandidateProfile({
const { can, user } = useAuth() const { can, user } = useAuth()
const isManager = isHiringManager(user) const isManager = isHiringManager(user)
const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS 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 { data: interviews = [] } = useQuery(seedQuery('interviews'))
const isLive = Boolean(c.userId) const isLive = Boolean(c.userId)

View File

@ -8,7 +8,7 @@
============================================================ */ ============================================================ */
import { useEffect, useMemo, useRef, useState } from 'react' 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 { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import AiFieldAssist from '../ui/AiFieldAssist' import AiFieldAssist from '../ui/AiFieldAssist'
@ -83,6 +83,7 @@ export default function Jobs() {
const { can } = useAuth() const { can } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [searchParams, setSearchParams] = useSearchParams()
const qc = useQueryClient() const qc = useQueryClient()
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
@ -106,26 +107,43 @@ export default function Jobs() {
const [type, setType] = useState('') const [type, setType] = useState('')
const [viewing, setViewing] = useState(null) const [viewing, setViewing] = useState(null)
const [viewingTab, setViewingTab] = useState('details')
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const canEdit = can('jobs.edit') const canEdit = can('jobs.edit')
const canDelete = can('jobs.delete') const canDelete = can('jobs.delete')
// Deep-link intents from global search, the dashboard and the manager portal. // Deep-link intents from notifications, global search, the dashboard and the
// Consume once and replace history state: jobs refetch after a status PATCH // manager portal. Consume once and replace history: jobs refetch after a
// used to replay openCreate and pop the create modal over the detail view. // status PATCH used to replay openCreate and pop the create modal over the
// detail view. `/jobs?job=` / `?tab=history` is the notification target.
useEffect(() => { useEffect(() => {
const st = location.state const st = location.state
if (!st?.openCreate && !st?.openJob) return const jobId = searchParams.get('job') || st?.openJob
if (st.openCreate) setCreating(true) const tab = String(searchParams.get('tab') || '').toLowerCase()
if (st.openJob) { if (!st?.openCreate && !jobId) return
const job = jobs.find((j) => j.id === st.openJob) if (st?.openCreate) setCreating(true)
if (job) setViewing(job) if (jobId) {
else if (!jobsQuery.isSuccess) return 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 }) const next = new URLSearchParams(searchParams)
}, [location.state, jobs, jobsQuery.isSuccess, navigate]) 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(() => { useEffect(() => {
if (!viewing) return if (!viewing) return
@ -151,6 +169,7 @@ export default function Jobs() {
onSuccess: (res) => { onSuccess: (res) => {
qc.invalidateQueries({ queryKey: qk.jobs.all() }) qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.requisitions.all() }) qc.invalidateQueries({ queryKey: qk.requisitions.all() })
qc.invalidateQueries({ queryKey: qk.notifications.all() })
setCreating(false) setCreating(false)
if (res?.imageFailed) toast('Job created, but the cover image failed to upload.', 'error') if (res?.imageFailed) toast('Job created, but the cover image failed to upload.', 'error')
else toast('Job created', 'success') else toast('Job created', 'success')
@ -184,6 +203,7 @@ export default function Jobs() {
mutationFn: ({ id, status: next }) => jobsApi.setStatus(id, next), mutationFn: ({ id, status: next }) => jobsApi.setStatus(id, next),
onSuccess: (_d, vars) => { onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: qk.jobs.all() }) qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.notifications.all() })
toast(`Status set to ${vars.status}`, 'success') toast(`Status set to ${vars.status}`, 'success')
}, },
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'), onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
@ -368,12 +388,14 @@ export default function Jobs() {
{viewing && ( {viewing && (
<JobDetail <JobDetail
key={viewing.id}
job={viewing} job={viewing}
initialTab={viewingTab}
canEdit={canEdit} canEdit={canEdit}
canDelete={canDelete} canDelete={canDelete}
statusBusy={setJobStatus.isPending} statusBusy={setJobStatus.isPending}
deleteBusy={deleteJob.isPending} deleteBusy={deleteJob.isPending}
onClose={() => setViewing(null)} onClose={() => { setViewing(null); setViewingTab('details') }}
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }} onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
onEdit={() => { setEditing(viewing); setViewing(null) }} onEdit={() => { setEditing(viewing); setViewing(null) }}
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })} onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
@ -1320,10 +1342,10 @@ function JobCover({ jobId }) {
} }
function JobDetail({ function JobDetail({
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete, job: j, initialTab = 'details', canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
statusLabels = jobsApi.JOB_STATUSES, statusLabels = jobsApi.JOB_STATUSES,
}) { }) {
const [tab, setTab] = useState('details') const [tab, setTab] = useState(initialTab === 'history' ? 'history' : 'details')
const historyQuery = useQuery({ const historyQuery = useQuery({
queryKey: qk.assignments.job(j.id), queryKey: qk.assignments.job(j.id),
queryFn: async () => { queryFn: async () => {

View File

@ -118,9 +118,15 @@ function JobDetail({ job }) {
)} )}
</p> </p>
</div> </div>
<div className="progress-selected-total"> <div className="progress-selected-totals">
<strong>{job.total.toLocaleString()}</strong> <div className="progress-selected-total">
<span>unique applicants</span> <strong>{job.total.toLocaleString()}</strong>
<span>unique applicants</span>
</div>
<div className="progress-selected-total">
<strong className={job.reapplied ? 'accent' : undefined}>{job.reapplied.toLocaleString()}</strong>
<span>reapplied</span>
</div>
</div> </div>
</div> </div>
@ -162,6 +168,11 @@ function JobDetail({ job }) {
<Metric value={interviewRate == null ? '—' : `${interviewRate}%`} label="Interview rate" /> <Metric value={interviewRate == null ? '—' : `${interviewRate}%`} label="Interview rate" />
<Metric value={job.offered.toLocaleString()} label="Offers made" accent /> <Metric value={job.offered.toLocaleString()} label="Offers made" accent />
<Metric value={offerRate == null ? '—' : `${offerRate}%`} label="Offer-to-hire" /> <Metric value={offerRate == null ? '—' : `${offerRate}%`} label="Offer-to-hire" />
<Metric
value={job.reapplied.toLocaleString()}
label={job.total ? `Reapplied (${Math.round((job.reapplied / job.total) * 100)}%)` : 'Reapplied'}
accent={job.reapplied > 0}
/>
</div> </div>
<div className="progress-bottom-grid"> <div className="progress-bottom-grid">
@ -172,6 +183,10 @@ function JobDetail({ job }) {
<span>Candidates on hold</span> <span>Candidates on hold</span>
<strong className="text-warning">{job.onHold}</strong> <strong className="text-warning">{job.onHold}</strong>
</div> </div>
<div className="progress-attention-item">
<span>Reapplied</span>
<strong className={job.reapplied ? 'text-warning' : undefined}>{job.reapplied}</strong>
</div>
<div className="progress-attention-item"> <div className="progress-attention-item">
<span>Job aging</span> <span>Job aging</span>
<strong> <strong>

View File

@ -2015,6 +2015,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
color: var(--text-2); font-size: var(--fs-sm); margin: 0; color: var(--text-2); font-size: var(--fs-sm); margin: 0;
} }
.progress-meta svg { width: 14px; height: 14px; vertical-align: -2px; margin-right: 4px; } .progress-meta svg { width: 14px; height: 14px; vertical-align: -2px; margin-right: 4px; }
.progress-selected-totals { display: flex; gap: 22px; align-items: flex-start; }
.progress-selected-total { .progress-selected-total {
display: flex; flex-direction: column; align-items: center; text-align: center; min-width: 110px; display: flex; flex-direction: column; align-items: center; text-align: center; min-width: 110px;
} }
@ -2022,6 +2023,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
display: block; font-family: var(--font-display); font-size: 28px; font-weight: 600; display: block; font-family: var(--font-display); font-size: 28px; font-weight: 600;
letter-spacing: -.02em; line-height: 1; color: var(--text); letter-spacing: -.02em; line-height: 1; color: var(--text);
} }
.progress-selected-total strong.accent { color: var(--warning); }
.progress-selected-total span { .progress-selected-total span {
display: block; font-size: var(--fs-xs); color: var(--text-2); margin-top: 4px; display: block; font-size: var(--fs-xs); color: var(--text-2); margin-top: 4px;
} }
@ -2130,7 +2132,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.progress-stage-row-pct { text-align: right; font-size: var(--fs-sm); color: var(--text-2); } .progress-stage-row-pct { text-align: right; font-size: var(--fs-sm); color: var(--text-2); }
.progress-metric-grid { .progress-metric-grid {
display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr)); display: grid; grid-template-columns: repeat(5, minmax(90px, 1fr));
gap: 1px; background: var(--border); border: 1px solid var(--border); gap: 1px; background: var(--border); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden; border-radius: var(--radius); overflow: hidden;
} }
@ -2169,6 +2171,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.progress-header-stats { width: 100%; justify-content: space-between; } .progress-header-stats { width: 100%; justify-content: space-between; }
.progress-stage-row { grid-template-columns: 96px minmax(80px, 1fr) 44px 36px; gap: 8px; } .progress-stage-row { grid-template-columns: 96px minmax(80px, 1fr) 44px 36px; gap: 8px; }
.progress-selected-total { align-items: flex-start; text-align: left; } .progress-selected-total { align-items: flex-start; text-align: left; }
.progress-selected-totals { width: 100%; justify-content: flex-start; }
.progress-sidebar-controls { flex-wrap: wrap; } .progress-sidebar-controls { flex-wrap: wrap; }
.progress-sort-chip { width: 100%; text-align: center; } .progress-sort-chip { width: 100%; text-align: center; }
} }