Merge pull request 'SQS_BROKER' (#73) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 39s
Details
Deploy to S3 / deploy (push) Successful in 39s
Details
Reviewed-on: #73pull/74/head
commit
3086f3fca7
|
|
@ -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
|
|
||||||
|
|
@ -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,8 +27,7 @@ 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
|
||||||
|
|
@ -79,11 +39,6 @@ jobs:
|
||||||
./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 }}
|
||||||
|
|
@ -92,3 +47,10 @@ jobs:
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 [],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,8 @@ export function useBadges() {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
staleTime: 0,
|
||||||
|
refetchInterval: 15_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue