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
# main only. Everything else is covered by ci.yml, which runs the same checks
# without deploying.
on:
push:
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,8 +27,7 @@ jobs:
zip -r utopia-ai-hr-ats-portal.zip . \
-x ".git/*" \
-x ".gitea/*" \
-x ".gitignore" \
-x "frontend/node_modules/*" \
-x ".gitignore/*" \
-x "*.DS_Store"
- name: Install AWS CLI
@ -79,11 +39,6 @@ jobs:
./aws/install
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 }}
@ -92,3 +47,10 @@ jobs:
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

View File

@ -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 [],
)

View File

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

View File

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

View File

@ -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),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 && (
<JobDetail
key={viewing.id}
job={viewing}
initialTab={viewingTab}
canEdit={canEdit}
canDelete={canDelete}
statusBusy={setJobStatus.isPending}
deleteBusy={deleteJob.isPending}
onClose={() => setViewing(null)}
onClose={() => { setViewing(null); setViewingTab('details') }}
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
onEdit={() => { setEditing(viewing); setViewing(null) }}
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
@ -1320,10 +1342,10 @@ function JobCover({ jobId }) {
}
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,
}) {
const [tab, setTab] = useState('details')
const [tab, setTab] = useState(initialTab === 'history' ? 'history' : 'details')
const historyQuery = useQuery({
queryKey: qk.assignments.job(j.id),
queryFn: async () => {

View File

@ -118,10 +118,16 @@ function JobDetail({ job }) {
)}
</p>
</div>
<div className="progress-selected-totals">
<div className="progress-selected-total">
<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 className="progress-bar-wrap">
@ -162,6 +168,11 @@ function JobDetail({ job }) {
<Metric value={interviewRate == null ? '—' : `${interviewRate}%`} label="Interview rate" />
<Metric value={job.offered.toLocaleString()} label="Offers made" accent />
<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 className="progress-bottom-grid">
@ -172,6 +183,10 @@ function JobDetail({ job }) {
<span>Candidates on hold</span>
<strong className="text-warning">{job.onHold}</strong>
</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">
<span>Job aging</span>
<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;
}
.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 {
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;
letter-spacing: -.02em; line-height: 1; color: var(--text);
}
.progress-selected-total strong.accent { color: var(--warning); }
.progress-selected-total span {
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-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);
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-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-totals { width: 100%; justify-content: flex-start; }
.progress-sidebar-controls { flex-wrap: wrap; }
.progress-sort-chip { width: 100%; text-align: center; }
}