Merge pull request 'reapplied logic correct' (#76) from SQS_BROKER into main
Reviewed-on: #76pull/77/head^2
commit
3d032a5069
|
|
@ -0,0 +1,36 @@
|
||||||
|
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,25 +1,64 @@
|
||||||
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:
|
||||||
deploy:
|
# 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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Configure AWS credentials
|
# 22 to match frontend/Dockerfile, so CI resolves the same tree the
|
||||||
env:
|
# production image builds from.
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }}
|
- name: Set up Node
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }}
|
uses: actions/setup-node@v4
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
with:
|
||||||
run: |
|
node-version: '22'
|
||||||
echo "AWS credentials configured"
|
|
||||||
|
|
||||||
|
# 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: Archive project
|
- name: Archive project
|
||||||
run: |
|
run: |
|
||||||
apt-get update -y
|
apt-get update -y
|
||||||
|
|
@ -27,8 +66,9 @@ 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 "*.DS_Store"
|
-x "frontend/node_modules/*" \
|
||||||
|
-x "*.DS_Store"
|
||||||
|
|
||||||
- name: Install AWS CLI
|
- name: Install AWS CLI
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -37,20 +77,18 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,7 @@ class Inbox(SQLModel, table=True):
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(
|
select(
|
||||||
Inbox.id.label("inbox_id"),
|
Inbox.id.label("inbox_id"),
|
||||||
|
Inbox.user_id.label("user_id"),
|
||||||
Inbox_Messages.id.label("message_pk"),
|
Inbox_Messages.id.label("message_pk"),
|
||||||
Inbox_Messages.message_id.label("upstream_id"),
|
Inbox_Messages.message_id.label("upstream_id"),
|
||||||
Inbox_Messages.message_from,
|
Inbox_Messages.message_from,
|
||||||
|
|
@ -219,6 +220,7 @@ class Inbox(SQLModel, table=True):
|
||||||
"manual_upload_candidate_id": None,
|
"manual_upload_candidate_id": None,
|
||||||
"form_data_id": None,
|
"form_data_id": None,
|
||||||
"candidate_id": None,
|
"candidate_id": None,
|
||||||
|
"user_id": str(row["user_id"]) if row["user_id"] else None,
|
||||||
"job_post_id": str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None,
|
"job_post_id": str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None,
|
||||||
"job_title": row["title"] or None,
|
"job_title": row["title"] or None,
|
||||||
"status": status.value if status else None,
|
"status": status.value if status else None,
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict:
|
||||||
"subject": message.message_subject,
|
"subject": message.message_subject,
|
||||||
"body": message.message_body,
|
"body": message.message_body,
|
||||||
"when": message.message_received_time,
|
"when": message.message_received_time,
|
||||||
|
"received": message.message_received_time,
|
||||||
"unread": not message.message_read,
|
"unread": not message.message_read,
|
||||||
"attachment": message.attachment,
|
"attachment": message.attachment,
|
||||||
"attachment_name": attachment_name,
|
"attachment_name": attachment_name,
|
||||||
|
|
@ -113,6 +114,7 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light:
|
||||||
processing = "Read" if message.message_read else "Unread"
|
processing = "Read" if message.message_read else "Unread"
|
||||||
payload = {
|
payload = {
|
||||||
"id": str(message.id),
|
"id": str(message.id),
|
||||||
|
"message_id": str(message.message_id) if message.message_id else None,
|
||||||
"name": _sender_name(message, light=light),
|
"name": _sender_name(message, light=light),
|
||||||
"email": message.message_from,
|
"email": message.message_from,
|
||||||
"position": message.message_subject,
|
"position": message.message_subject,
|
||||||
|
|
|
||||||
|
|
@ -425,6 +425,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
"manual_upload_candidate_id": str(rec.id),
|
"manual_upload_candidate_id": str(rec.id),
|
||||||
"form_data_id": None,
|
"form_data_id": None,
|
||||||
"candidate_id": None,
|
"candidate_id": None,
|
||||||
|
"user_id": str(rec.user_id) if rec.user_id else None,
|
||||||
"job_post_id": str(rec.job_post_id) if rec.job_post_id else None,
|
"job_post_id": str(rec.job_post_id) if rec.job_post_id else None,
|
||||||
"job_title": title or None,
|
"job_title": title or None,
|
||||||
"status": status or "PENDING",
|
"status": status or "PENDING",
|
||||||
|
|
|
||||||
|
|
@ -384,9 +384,11 @@ def serialize_application_history_item(row) -> dict:
|
||||||
"source": row.get("source"),
|
"source": row.get("source"),
|
||||||
"inbox_id": row.get("inbox_id"),
|
"inbox_id": row.get("inbox_id"),
|
||||||
"message_id": row.get("message_id"),
|
"message_id": row.get("message_id"),
|
||||||
|
"upstream_id": row.get("upstream_id"),
|
||||||
"manual_upload_candidate_id": row.get("manual_upload_candidate_id"),
|
"manual_upload_candidate_id": row.get("manual_upload_candidate_id"),
|
||||||
"form_data_id": row.get("form_data_id"),
|
"form_data_id": row.get("form_data_id"),
|
||||||
"candidate_id": row.get("candidate_id"),
|
"candidate_id": row.get("candidate_id"),
|
||||||
|
"user_id": str(row.get("user_id")) if row.get("user_id") else None,
|
||||||
"job_post_id": row.get("job_post_id"),
|
"job_post_id": row.get("job_post_id"),
|
||||||
"job_title": row.get("job_title"),
|
"job_title": row.get("job_title"),
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import asyncio,base64,dataclasses,hashlib,io,logging,os,uuid
|
import asyncio,base64,dataclasses,hashlib,io,logging,os,uuid
|
||||||
from datetime import datetime,timezone
|
from datetime import date,datetime,timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
@ -68,45 +68,90 @@ def _payload_email(payload):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ID_KEYS=(
|
||||||
|
"id","inbox_id","message_id","upstream_id",
|
||||||
|
"form_data_id","manual_upload_candidate_id","candidate_id",
|
||||||
|
)
|
||||||
|
_PAYLOAD_TIME_KEYS=(
|
||||||
|
"received","when","message_sent_time","message_received_time",
|
||||||
|
"entry_date","applied_at","created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_ids(obj):
|
||||||
|
"""Stable identifiers for one application row — not email, not job_post_id.
|
||||||
|
|
||||||
|
List payloads use `source` for the Outlook To address, so matching by source
|
||||||
|
string cannot work. Shared id values are what make 'this row' the same
|
||||||
|
application the recruiter just clicked.
|
||||||
|
"""
|
||||||
|
ids=set()
|
||||||
|
if not isinstance(obj,dict):
|
||||||
|
return ids
|
||||||
|
for key in _ID_KEYS:
|
||||||
|
value=obj.get(key)
|
||||||
|
if value is None or value=="":
|
||||||
|
continue
|
||||||
|
ids.add(str(value))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
def _is_current_application(item,payload):
|
def _is_current_application(item,payload):
|
||||||
"""True when `item` is the same row the list/detail payload is showing."""
|
"""True when `item` is the same row the list/detail payload is showing."""
|
||||||
if not isinstance(item,dict) or not isinstance(payload,dict):
|
if not isinstance(item,dict) or not isinstance(payload,dict):
|
||||||
return False
|
return False
|
||||||
source=item.get("source")
|
return bool(_row_ids(item)&_row_ids(payload))
|
||||||
if source in ("inbox","filtered"):
|
|
||||||
if payload.get("inbox_id") is not None and item.get("inbox_id") is not None:
|
|
||||||
try:
|
def _as_utc(value):
|
||||||
if int(payload["inbox_id"])==int(item["inbox_id"]):
|
if value is None or value=="":
|
||||||
return True
|
return None
|
||||||
except (TypeError,ValueError):
|
if isinstance(value,datetime):
|
||||||
pass
|
dt=value
|
||||||
pid=payload.get("id")
|
elif isinstance(value,date):
|
||||||
if pid and item.get("message_id") and str(pid)==str(item["message_id"]):
|
dt=datetime(value.year,value.month,value.day,tzinfo=timezone.utc)
|
||||||
return True
|
else:
|
||||||
graph=payload.get("message_id")
|
raw=str(value).strip()
|
||||||
if graph:
|
if not raw:
|
||||||
if item.get("upstream_id") and str(graph)==str(item["upstream_id"]):
|
return None
|
||||||
return True
|
if raw.endswith("Z"):
|
||||||
if item.get("message_id") and str(graph)==str(item["message_id"]):
|
raw=raw[:-1]+"+00:00"
|
||||||
return True
|
elif "T" not in raw[:20] and " " in raw:
|
||||||
|
raw=raw.replace(" ","T",1)
|
||||||
|
try:
|
||||||
|
dt=datetime.fromisoformat(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt=dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_applied_at(payload):
|
||||||
|
if not isinstance(payload,dict):
|
||||||
|
return None
|
||||||
|
for key in _PAYLOAD_TIME_KEYS:
|
||||||
|
dt=_as_utc(payload.get(key))
|
||||||
|
if dt is not None:
|
||||||
|
return dt
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_earlier_application(item,payload):
|
||||||
|
"""True when `item` happened before the open application.
|
||||||
|
|
||||||
|
A later mail from the same person is not a previous attempt. Missing
|
||||||
|
timestamps cannot be ordered, so those rows stay visible.
|
||||||
|
"""
|
||||||
|
if not isinstance(item,dict) or not isinstance(payload,dict):
|
||||||
return False
|
return False
|
||||||
if source=="manual":
|
current=_payload_applied_at(payload)
|
||||||
pid=payload.get("manual_upload_candidate_id")
|
other=_as_utc(item.get("applied_at"))
|
||||||
if not pid and payload.get("inbox_id") is None and payload.get("sheet") is None:
|
if current is None:
|
||||||
pid=payload.get("id")
|
return True
|
||||||
return bool(pid and item.get("manual_upload_candidate_id") and str(pid)==str(item["manual_upload_candidate_id"]))
|
if other is None:
|
||||||
if source=="form":
|
return False
|
||||||
if payload.get("sheet") is None:
|
return other<current
|
||||||
return False
|
|
||||||
pid=payload.get("id")
|
|
||||||
return bool(pid and item.get("form_data_id") and str(pid)==str(item["form_data_id"]))
|
|
||||||
if source=="ats":
|
|
||||||
pid=payload.get("candidate_id") or payload.get("id")
|
|
||||||
return bool(
|
|
||||||
pid and item.get("candidate_id") and str(pid)==str(item["candidate_id"])
|
|
||||||
and (payload.get("match_score") is not None or payload.get("filename"))
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def assigned_job_ids_for_user(session,user_id):
|
async def assigned_job_ids_for_user(session,user_id):
|
||||||
|
|
@ -1715,7 +1760,10 @@ class CandidateView:
|
||||||
for row in pack.get("applications") or []:
|
for row in pack.get("applications") or []:
|
||||||
if _is_current_application(row,payload):
|
if _is_current_application(row,payload):
|
||||||
continue
|
continue
|
||||||
|
if not _is_earlier_application(row,payload):
|
||||||
|
continue
|
||||||
previous.append(serialize_application_history_item(row))
|
previous.append(serialize_application_history_item(row))
|
||||||
|
previous.sort(key=lambda r: r.get("applied_at") or "",reverse=True)
|
||||||
payload["present_in"]=list(pack.get("present_in") or [])
|
payload["present_in"]=list(pack.get("present_in") or [])
|
||||||
payload["is_reapplicant"]=any(is_assigned_application(item) for item in previous)
|
payload["is_reapplicant"]=any(is_assigned_application(item) for item in previous)
|
||||||
payload["previous_applications"]=previous
|
payload["previous_applications"]=previous
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from job.candidate.serializers import (
|
||||||
serialize_application_history,
|
serialize_application_history,
|
||||||
serialize_application_history_item,
|
serialize_application_history_item,
|
||||||
)
|
)
|
||||||
from job.candidate.views import _is_current_application
|
from job.candidate.views import _is_current_application, _is_earlier_application
|
||||||
|
|
||||||
|
|
||||||
def test_unassigned_inbox_is_not_a_reapplication():
|
def test_unassigned_inbox_is_not_a_reapplication():
|
||||||
|
|
@ -74,14 +74,58 @@ def test_history_reapplicant_needs_an_assigned_job():
|
||||||
assert len(both["applications"]) == 2
|
assert len(both["applications"]) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_current_inbox_row_matches_message_pk():
|
def test_clicked_inbox_row_is_not_a_previous_application():
|
||||||
item = {"source": "inbox", "message_id": "11111111-1111-1111-1111-111111111111"}
|
"""List payloads use `source` for the To address, not 'inbox'."""
|
||||||
payload = {"id": "11111111-1111-1111-1111-111111111111", "email": "a@x.com"}
|
pk = "11111111-1111-1111-1111-111111111111"
|
||||||
|
item = {"source": "inbox", "message_id": pk, "inbox_id": 9, "job_post_id": None}
|
||||||
|
payload = {
|
||||||
|
"id": pk,
|
||||||
|
"email": "a@x.com",
|
||||||
|
"source": "careers-rozee@example.com",
|
||||||
|
"position": "Backend Engineer",
|
||||||
|
}
|
||||||
assert _is_current_application(item, payload) is True
|
assert _is_current_application(item, payload) is True
|
||||||
assert _is_current_application(item, {"id": "other"}) is False
|
assert _is_current_application(item, {"id": "other", "source": "careers-rozee@example.com"}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_row_matches_without_a_sheet_field():
|
||||||
|
fid = "22222222-2222-2222-2222-222222222222"
|
||||||
|
item = {"source": "form", "form_data_id": fid, "job_title": "Analyst"}
|
||||||
|
assert _is_current_application(item, {"id": fid, "candidate_email": "a@x.com"}) is True
|
||||||
|
|
||||||
|
|
||||||
def test_filtered_row_matches_graph_id_on_detail_payload():
|
def test_filtered_row_matches_graph_id_on_detail_payload():
|
||||||
item = {"source": "filtered", "message_id": "AAMkGraph", "upstream_id": "AAMkGraph"}
|
item = {"source": "filtered", "message_id": "AAMkGraph", "upstream_id": "AAMkGraph"}
|
||||||
payload = {"message_id": "AAMkGraph", "fromEmail": "a@x.com"}
|
payload = {"id": "11111111-1111-1111-1111-111111111111", "message_id": "AAMkGraph"}
|
||||||
assert _is_current_application(item, payload) is True
|
assert _is_current_application(item, payload) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_later_mail_is_not_a_previous_application():
|
||||||
|
"""12:52 is later than 12:51 — it is not a previous attempt of the 12:51 row."""
|
||||||
|
later = {"source": "inbox", "message_id": "b", "applied_at": "2026-09-07T07:52:00+00:00"}
|
||||||
|
payload = {"id": "a", "received": "2026-09-07T07:51:00+00:00"}
|
||||||
|
assert _is_current_application(later, payload) is False
|
||||||
|
assert _is_earlier_application(later, payload) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_earlier_mail_is_a_previous_application():
|
||||||
|
earlier = {"source": "inbox", "message_id": "a", "applied_at": "2026-09-07T07:51:00+00:00"}
|
||||||
|
payload = {"id": "b", "received": "2026-09-07T07:52:00+00:00"}
|
||||||
|
assert _is_earlier_application(earlier, payload) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_undated_row_is_not_guessed_earlier():
|
||||||
|
item = {"source": "inbox", "message_id": "b", "applied_at": None}
|
||||||
|
payload = {"id": "a", "received": "2026-09-07T07:51:00+00:00"}
|
||||||
|
assert _is_earlier_application(item, payload) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_item_keeps_user_id():
|
||||||
|
item = serialize_application_history_item({
|
||||||
|
"source": "manual",
|
||||||
|
"user_id": "user-1",
|
||||||
|
"manual_upload_candidate_id": "m-1",
|
||||||
|
"job_post_id": "job-1",
|
||||||
|
"job_title": "Analyst",
|
||||||
|
})
|
||||||
|
assert item["user_id"] == "user-1"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Badge } from '../ui/primitives'
|
import { Badge } from '../ui/primitives'
|
||||||
import { STAGE_FROM_STATUS } from '../api/pipeline'
|
import { STAGE_FROM_STATUS } from '../api/pipeline'
|
||||||
import { fmtDate } from '../lib/format'
|
import { fmtDateTime, toDate, toInstant } from '../lib/format'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
const SOURCE_LABEL = {
|
const SOURCE_LABEL = {
|
||||||
inbox: 'Email',
|
inbox: 'Email',
|
||||||
|
|
@ -49,9 +50,66 @@ export function applicationStatusLabel(status, item) {
|
||||||
|
|
||||||
export function previousApplicationsOf(row) {
|
export function previousApplicationsOf(row) {
|
||||||
if (!row) return []
|
if (!row) return []
|
||||||
if (Array.isArray(row.previousApplications)) return row.previousApplications
|
const raw = Array.isArray(row.previousApplications)
|
||||||
if (Array.isArray(row.previous_applications)) return row.previous_applications
|
? row.previousApplications
|
||||||
return []
|
: Array.isArray(row.previous_applications)
|
||||||
|
? row.previous_applications
|
||||||
|
: []
|
||||||
|
const current = currentRowIds(row)
|
||||||
|
const currentTs = appliedAtMs(
|
||||||
|
row.received || row.applied_at || row.entry_date || row.when || row.sentAt,
|
||||||
|
)
|
||||||
|
const items = raw.filter((item) => {
|
||||||
|
if (current.size && isSameApplication(item, current)) return false
|
||||||
|
const t = appliedAtMs(item?.applied_at)
|
||||||
|
if (currentTs == null) return true
|
||||||
|
if (t == null) return false
|
||||||
|
return t < currentTs
|
||||||
|
})
|
||||||
|
items.sort((a, b) => (appliedAtMs(a?.applied_at) ?? 0) - (appliedAtMs(b?.applied_at) ?? 0))
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function appliedAtMs(value) {
|
||||||
|
if (value == null || value === '') return null
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return Number.isNaN(value.getTime()) ? null : value.getTime()
|
||||||
|
}
|
||||||
|
const instant = toInstant(value)
|
||||||
|
if (instant) return instant.getTime()
|
||||||
|
const wall = toDate(value)
|
||||||
|
return wall ? wall.getTime() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentRowIds(row) {
|
||||||
|
return new Set(
|
||||||
|
[
|
||||||
|
row.id,
|
||||||
|
row.inboxId,
|
||||||
|
row.inbox_id,
|
||||||
|
row.message_id,
|
||||||
|
row.messageId,
|
||||||
|
row.form_data_id,
|
||||||
|
row.manualUploadId,
|
||||||
|
row.manual_upload_candidate_id,
|
||||||
|
row.candidate_id,
|
||||||
|
row.upstream_id,
|
||||||
|
]
|
||||||
|
.filter((v) => v != null && v !== '')
|
||||||
|
.map(String),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameApplication(item, currentIds) {
|
||||||
|
if (!item) return false
|
||||||
|
return [
|
||||||
|
item.inbox_id,
|
||||||
|
item.message_id,
|
||||||
|
item.upstream_id,
|
||||||
|
item.form_data_id,
|
||||||
|
item.manual_upload_candidate_id,
|
||||||
|
item.candidate_id,
|
||||||
|
].some((id) => id != null && id !== '' && currentIds.has(String(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAssignedJob(item) {
|
function hasAssignedJob(item) {
|
||||||
|
|
@ -62,9 +120,7 @@ function hasAssignedJob(item) {
|
||||||
|
|
||||||
export function isReapplicant(row) {
|
export function isReapplicant(row) {
|
||||||
if (!row) return false
|
if (!row) return false
|
||||||
const previous = previousApplicationsOf(row)
|
return previousApplicationsOf(row).some(hasAssignedJob)
|
||||||
if (previous.length) return previous.some(hasAssignedJob)
|
|
||||||
return row.isReapplicant === true || row.is_reapplicant === true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function previousApplicationsTip(row) {
|
export function previousApplicationsTip(row) {
|
||||||
|
|
@ -91,6 +147,23 @@ export function ReappliedBadge({ row, className = '' }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hrefForPreviousApplication(item) {
|
||||||
|
if (!item) return null
|
||||||
|
if (item.source === 'form' && item.form_data_id) {
|
||||||
|
return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form`
|
||||||
|
}
|
||||||
|
if (item.source === 'inbox' && item.message_id) {
|
||||||
|
return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email`
|
||||||
|
}
|
||||||
|
if (item.source === 'manual') {
|
||||||
|
if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}`
|
||||||
|
if (item.manual_upload_candidate_id) {
|
||||||
|
return `/matching?record=${encodeURIComponent(item.manual_upload_candidate_id)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** Full prior-job list for profile / inbox / add-candidate. */
|
/** Full prior-job list for profile / inbox / add-candidate. */
|
||||||
export function PreviousApplications({ row, title = 'Previous applications' }) {
|
export function PreviousApplications({ row, title = 'Previous applications' }) {
|
||||||
const items = previousApplicationsOf(row)
|
const items = previousApplicationsOf(row)
|
||||||
|
|
@ -120,6 +193,8 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{items.map((item, idx) => {
|
{items.map((item, idx) => {
|
||||||
const stage = applicationStatusLabel(item.status, item)
|
const stage = applicationStatusLabel(item.status, item)
|
||||||
|
const job = item.job_title || item.jobTitle || 'No job assigned'
|
||||||
|
const href = hrefForPreviousApplication(item)
|
||||||
const key = [
|
const key = [
|
||||||
item.source,
|
item.source,
|
||||||
item.inbox_id,
|
item.inbox_id,
|
||||||
|
|
@ -132,16 +207,25 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="flex items-center gap-8"
|
className={`reapplicant-history-row flex items-center gap-8${href ? ' is-link' : ''}`}
|
||||||
style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}
|
style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div className="fw-600" style={{ fontSize: 13 }}>
|
{href ? (
|
||||||
{item.job_title || item.jobTitle || 'No job assigned'}
|
<Link
|
||||||
</div>
|
to={href}
|
||||||
|
className="reapplicant-job-link"
|
||||||
|
title="Open this previous application"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{job}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="fw-600" style={{ fontSize: 13 }}>{job}</div>
|
||||||
|
)}
|
||||||
<div className="cell-sub">
|
<div className="cell-sub">
|
||||||
{SOURCE_LABEL[item.source] || item.source || 'Application'}
|
{SOURCE_LABEL[item.source] || item.source || 'Application'}
|
||||||
{item.applied_at ? ` · ${fmtDate(item.applied_at)}` : ''}
|
{item.applied_at ? ` · ${fmtDateTime(toInstant(item.applied_at) || item.applied_at)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={STAGE_BADGE[stage] || 'b-gray'}>{stage}</Badge>
|
<Badge className={STAGE_BADGE[stage] || 'b-gray'}>{stage}</Badge>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { 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 Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
|
|
@ -159,6 +159,10 @@ function parseGraphDate(value) {
|
||||||
return toInstant(value)
|
return toInstant(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameInboxId(a, b) {
|
||||||
|
return a != null && b != null && String(a) === String(b)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `source` arrives as the raw To address, because that is where the board tag
|
* `source` arrives as the raw To address, because that is where the board tag
|
||||||
* lands — careers-rozee@, employee-referral@, mustakbil@ and so on. Strip
|
* lands — careers-rozee@, employee-referral@, mustakbil@ and so on. Strip
|
||||||
|
|
@ -418,6 +422,7 @@ async function fetchMessageDetail(recordId) {
|
||||||
return {
|
return {
|
||||||
kind: 'email',
|
kind: 'email',
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
|
message_id: row.message_id || null,
|
||||||
name,
|
name,
|
||||||
initials: initialsOf(name),
|
initials: initialsOf(name),
|
||||||
color: avatarColor(name),
|
color: avatarColor(name),
|
||||||
|
|
@ -474,6 +479,7 @@ async function fetchApplications(params) {
|
||||||
return {
|
return {
|
||||||
kind: 'email',
|
kind: 'email',
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
|
message_id: row.message_id || null,
|
||||||
name,
|
name,
|
||||||
initials: initialsOf(name),
|
initials: initialsOf(name),
|
||||||
color: avatarColor(name),
|
color: avatarColor(name),
|
||||||
|
|
@ -949,6 +955,7 @@ function QueueFreshness({ at, refreshing, onRefresh }) {
|
||||||
export default function Inbox() {
|
export default function Inbox() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { can } = useAuth()
|
const { can } = useAuth()
|
||||||
const canEdit = can('inbox.edit')
|
const canEdit = can('inbox.edit')
|
||||||
|
|
@ -964,6 +971,7 @@ export default function Inbox() {
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [assigning, setAssigning] = useState(null)
|
const [assigning, setAssigning] = useState(null)
|
||||||
const [noting, setNoting] = useState(null)
|
const [noting, setNoting] = useState(null)
|
||||||
|
const [openKindHint, setOpenKindHint] = useState(null)
|
||||||
|
|
||||||
// The box updates on every keystroke; the QUERY KEY only settles when typing
|
// The box updates on every keystroke; the QUERY KEY only settles when typing
|
||||||
// pauses. Untyped, each character produced a fresh key, an in-flight request
|
// pauses. Untyped, each character produced a fresh key, an in-flight request
|
||||||
|
|
@ -986,6 +994,21 @@ export default function Inbox() {
|
||||||
setSelectedId(null)
|
setSelectedId(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const deepOpen = searchParams.get('open')
|
||||||
|
const deepKind = searchParams.get('kind')
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deepOpen) return
|
||||||
|
const kind = deepKind === 'form' ? 'form' : 'email'
|
||||||
|
setOpenKindHint(kind)
|
||||||
|
setChannel(kind === 'form' ? 'forms' : 'email')
|
||||||
|
setTab('All Applications')
|
||||||
|
setSkip(0)
|
||||||
|
setQ('')
|
||||||
|
setSearch('')
|
||||||
|
setSelectedId(deepOpen)
|
||||||
|
setSearchParams({}, { replace: true })
|
||||||
|
}, [deepOpen, deepKind, setSearchParams])
|
||||||
|
|
||||||
const isForms = channel === 'forms'
|
const isForms = channel === 'forms'
|
||||||
// Combined channel: both sources fetched UNPAGED (each endpoint reads a
|
// Combined channel: both sources fetched UNPAGED (each endpoint reads a
|
||||||
// missing top/limit as no LIMIT), merged by date, and paged client-side —
|
// missing top/limit as no LIMIT), merged by date, and paged client-side —
|
||||||
|
|
@ -1196,7 +1219,8 @@ export default function Inbox() {
|
||||||
const list = isAllChannel && !showAll ? inbox.slice(skip, skip + pageSize) : inbox
|
const list = isAllChannel && !showAll ? inbox.slice(skip, skip + pageSize) : inbox
|
||||||
|
|
||||||
// Mixed rows: the row's own kind picks the detail endpoint, not the channel.
|
// Mixed rows: the row's own kind picks the detail endpoint, not the channel.
|
||||||
const selectedKind = inbox.find((i) => i.id === selectedId)?.kind
|
const selectedKind = inbox.find((i) => sameInboxId(i.id, selectedId))?.kind
|
||||||
|
?? openKindHint
|
||||||
?? (isForms ? 'form' : 'email')
|
?? (isForms ? 'form' : 'email')
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
queryKey: selectedKind === 'form' ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId),
|
queryKey: selectedKind === 'form' ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId),
|
||||||
|
|
@ -1216,9 +1240,13 @@ export default function Inbox() {
|
||||||
))
|
))
|
||||||
}, [isForms, list, detailQuery.data])
|
}, [isForms, list, detailQuery.data])
|
||||||
|
|
||||||
const selectedRow = sidebar.find((i) => i.id === selectedId)
|
const selectedRow = sidebar.find((i) => sameInboxId(i.id, selectedId))
|
||||||
const selected = selectedRow || detailQuery.data
|
const selected = selectedRow || detailQuery.data
|
||||||
? { ...selectedRow, ...(detailQuery.data ?? {}) }
|
? {
|
||||||
|
...selectedRow,
|
||||||
|
...(detailQuery.data ?? {}),
|
||||||
|
received: selectedRow?.received ?? detailQuery.data?.received ?? null,
|
||||||
|
}
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// Bound to `list`, not `inbox`: the tick boxes sit on the rows the user can
|
// Bound to `list`, not `inbox`: the tick boxes sit on the rows the user can
|
||||||
|
|
@ -1263,6 +1291,7 @@ export default function Inbox() {
|
||||||
setChannel(next)
|
setChannel(next)
|
||||||
setSkip(0)
|
setSkip(0)
|
||||||
setSelectedId(null)
|
setSelectedId(null)
|
||||||
|
setOpenKindHint(null)
|
||||||
setQ('')
|
setQ('')
|
||||||
setSearch('') // clear the committed term too, or the new channel's first
|
setSearch('') // clear the committed term too, or the new channel's first
|
||||||
// fetch carries the old channel's search for 300ms
|
// fetch carries the old channel's search for 300ms
|
||||||
|
|
@ -1341,12 +1370,13 @@ export default function Inbox() {
|
||||||
|
|
||||||
function select(id) {
|
function select(id) {
|
||||||
setSelectedId(id)
|
setSelectedId(id)
|
||||||
|
setOpenKindHint(null)
|
||||||
// Phones swap the list for the detail pane — bring its top into view.
|
// Phones swap the list for the detail pane — bring its top into view.
|
||||||
if (window.matchMedia?.('(max-width: 900px)').matches) {
|
if (window.matchMedia?.('(max-width: 900px)').matches) {
|
||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
document.querySelector('.content')?.scrollTo?.(0, 0)
|
document.querySelector('.content')?.scrollTo?.(0, 0)
|
||||||
}
|
}
|
||||||
const item = inbox.find((i) => i.id === id)
|
const item = inbox.find((i) => sameInboxId(i.id, id))
|
||||||
if (item?.kind === 'form') return // sheet rows have no mailbox read state
|
if (item?.kind === 'form') return // sheet rows have no mailbox read state
|
||||||
if (item?.unread) setRead.mutate({ ids: [id], read: true })
|
if (item?.unread) setRead.mutate({ ids: [id], read: true })
|
||||||
}
|
}
|
||||||
|
|
@ -1645,7 +1675,7 @@ export default function Inbox() {
|
||||||
sidebar.map((i) => (
|
sidebar.map((i) => (
|
||||||
<div
|
<div
|
||||||
key={i.id}
|
key={i.id}
|
||||||
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
|
className={`inbox-item${i.unread ? ' unread' : ''}${sameInboxId(selectedId, i.id) ? ' active' : ''}`}
|
||||||
onClick={() => select(i.id)}
|
onClick={() => select(i.id)}
|
||||||
>
|
>
|
||||||
{i.kind !== 'form' && (
|
{i.kind !== 'form' && (
|
||||||
|
|
|
||||||
|
|
@ -1012,6 +1012,9 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
.inbox-item:hover { background: var(--bg-sunken); }
|
.inbox-item:hover { background: var(--bg-sunken); }
|
||||||
.inbox-item.active { background: var(--primary-soft); }
|
.inbox-item.active { background: var(--primary-soft); }
|
||||||
[data-theme="dark"] .inbox-item.active { background: var(--primary-soft); }
|
[data-theme="dark"] .inbox-item.active { background: var(--primary-soft); }
|
||||||
|
.reapplicant-history-row.is-link { background: var(--primary-soft); border-radius: 8px; padding: 8px 10px; margin: 0 -6px; }
|
||||||
|
.reapplicant-job-link { color: var(--primary); font-weight: 600; font-size: 13px; text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.reapplicant-job-link:hover { filter: brightness(1.08); }
|
||||||
.inbox-item.unread::before { content: ''; position: absolute; left: 6px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--primary); }
|
.inbox-item.unread::before { content: ''; position: absolute; left: 6px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--primary); }
|
||||||
.inbox-item.unread .ii-name { font-weight: 700; }
|
.inbox-item.unread .ii-name { font-weight: 700; }
|
||||||
.ii-main { flex: 1; min-width: 0; }
|
.ii-main { flex: 1; min-width: 0; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue