reapplied logic correct #76
|
|
@ -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
|
||||
|
||||
# main only. Everything else is covered by ci.yml, which runs the same checks
|
||||
# without deploying.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
branches:
|
||||
- main
|
||||
|
||||
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
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Configure AWS credentials
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
echo "AWS credentials configured"
|
||||
# 22 to match frontend/Dockerfile, so CI resolves the same tree the
|
||||
# production image builds from.
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# 3.11 is the floor in pyproject.toml and the version the project's conda
|
||||
# env runs.
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Run checks
|
||||
run: bash scripts/ci-checks.sh
|
||||
|
||||
deploy:
|
||||
needs: checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# frontend/node_modules is excluded, and that is safe because of what
|
||||
# happens to this object downstream. CodeDeploy pulls it, extracts to
|
||||
# /opt/codedeploy-extracted-5, copies the tree to
|
||||
# /home/ec2-user/utopia-ai-hr-ats-portal-deployment-group and runs
|
||||
# `docker compose --env-file ./backend/.env up -d --build`. The only Node
|
||||
# service is the frontend, whose image does `npm ci` from the lockfile,
|
||||
# and frontend/.dockerignore excludes node_modules/ from the build context
|
||||
# outright. So the committed tree was carried into every artifact and then
|
||||
# thrown away unread. It was 90 MB of a 33 MB compressed upload.
|
||||
#
|
||||
# node_modules is still tracked in git, which is the reason it was here at
|
||||
# all. Untracking it is a separate change and affects other branches.
|
||||
- name: Archive project
|
||||
run: |
|
||||
apt-get update -y
|
||||
|
|
@ -27,8 +66,9 @@ jobs:
|
|||
zip -r utopia-ai-hr-ats-portal.zip . \
|
||||
-x ".git/*" \
|
||||
-x ".gitea/*" \
|
||||
-x ".gitignore/*" \
|
||||
-x "*.DS_Store"
|
||||
-x ".gitignore" \
|
||||
-x "frontend/node_modules/*" \
|
||||
-x "*.DS_Store"
|
||||
|
||||
- name: Install AWS CLI
|
||||
run: |
|
||||
|
|
@ -37,20 +77,18 @@ jobs:
|
|||
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
unzip -q awscliv2.zip
|
||||
./aws/install
|
||||
aws --version
|
||||
aws --version
|
||||
|
||||
# The credentials live only on this step. There used to be a separate
|
||||
# "Configure AWS credentials" step above that set the same three variables
|
||||
# and then only echoed a message — env: is scoped to its own step, so
|
||||
# those values were discarded before anything could use them. It was doing
|
||||
# nothing, and it read as though credentials were set up globally.
|
||||
- name: Upload files to S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DEVOPS_USER_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEVOPS_USER_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
echo "Uploading repo contents to S3..."
|
||||
aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
echo "Uploading repo contents to S3..."
|
||||
aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ class Inbox(SQLModel, table=True):
|
|||
result = await session.execute(
|
||||
select(
|
||||
Inbox.id.label("inbox_id"),
|
||||
Inbox.user_id.label("user_id"),
|
||||
Inbox_Messages.id.label("message_pk"),
|
||||
Inbox_Messages.message_id.label("upstream_id"),
|
||||
Inbox_Messages.message_from,
|
||||
|
|
@ -219,6 +220,7 @@ class Inbox(SQLModel, table=True):
|
|||
"manual_upload_candidate_id": None,
|
||||
"form_data_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_title": row["title"] or 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,
|
||||
"body": message.message_body,
|
||||
"when": message.message_received_time,
|
||||
"received": message.message_received_time,
|
||||
"unread": not message.message_read,
|
||||
"attachment": message.attachment,
|
||||
"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"
|
||||
payload = {
|
||||
"id": str(message.id),
|
||||
"message_id": str(message.message_id) if message.message_id else None,
|
||||
"name": _sender_name(message, light=light),
|
||||
"email": message.message_from,
|
||||
"position": message.message_subject,
|
||||
|
|
|
|||
|
|
@ -425,6 +425,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
"manual_upload_candidate_id": str(rec.id),
|
||||
"form_data_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_title": title or None,
|
||||
"status": status or "PENDING",
|
||||
|
|
|
|||
|
|
@ -384,9 +384,11 @@ def serialize_application_history_item(row) -> dict:
|
|||
"source": row.get("source"),
|
||||
"inbox_id": row.get("inbox_id"),
|
||||
"message_id": row.get("message_id"),
|
||||
"upstream_id": row.get("upstream_id"),
|
||||
"manual_upload_candidate_id": row.get("manual_upload_candidate_id"),
|
||||
"form_data_id": row.get("form_data_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_title": row.get("job_title"),
|
||||
"status": status,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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 dotenv import load_dotenv
|
||||
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):
|
||||
"""True when `item` is the same row the list/detail payload is showing."""
|
||||
if not isinstance(item,dict) or not isinstance(payload,dict):
|
||||
return False
|
||||
source=item.get("source")
|
||||
if source in ("inbox","filtered"):
|
||||
if payload.get("inbox_id") is not None and item.get("inbox_id") is not None:
|
||||
try:
|
||||
if int(payload["inbox_id"])==int(item["inbox_id"]):
|
||||
return True
|
||||
except (TypeError,ValueError):
|
||||
pass
|
||||
pid=payload.get("id")
|
||||
if pid and item.get("message_id") and str(pid)==str(item["message_id"]):
|
||||
return True
|
||||
graph=payload.get("message_id")
|
||||
if graph:
|
||||
if item.get("upstream_id") and str(graph)==str(item["upstream_id"]):
|
||||
return True
|
||||
if item.get("message_id") and str(graph)==str(item["message_id"]):
|
||||
return True
|
||||
return bool(_row_ids(item)&_row_ids(payload))
|
||||
|
||||
|
||||
def _as_utc(value):
|
||||
if value is None or value=="":
|
||||
return None
|
||||
if isinstance(value,datetime):
|
||||
dt=value
|
||||
elif isinstance(value,date):
|
||||
dt=datetime(value.year,value.month,value.day,tzinfo=timezone.utc)
|
||||
else:
|
||||
raw=str(value).strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw.endswith("Z"):
|
||||
raw=raw[:-1]+"+00:00"
|
||||
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
|
||||
if source=="manual":
|
||||
pid=payload.get("manual_upload_candidate_id")
|
||||
if not pid and payload.get("inbox_id") is None and payload.get("sheet") is None:
|
||||
pid=payload.get("id")
|
||||
return bool(pid and item.get("manual_upload_candidate_id") and str(pid)==str(item["manual_upload_candidate_id"]))
|
||||
if source=="form":
|
||||
if payload.get("sheet") is None:
|
||||
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
|
||||
current=_payload_applied_at(payload)
|
||||
other=_as_utc(item.get("applied_at"))
|
||||
if current is None:
|
||||
return True
|
||||
if other is None:
|
||||
return False
|
||||
return other<current
|
||||
|
||||
|
||||
async def assigned_job_ids_for_user(session,user_id):
|
||||
|
|
@ -1715,7 +1760,10 @@ class CandidateView:
|
|||
for row in pack.get("applications") or []:
|
||||
if _is_current_application(row,payload):
|
||||
continue
|
||||
if not _is_earlier_application(row,payload):
|
||||
continue
|
||||
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["is_reapplicant"]=any(is_assigned_application(item) for item in previous)
|
||||
payload["previous_applications"]=previous
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from job.candidate.serializers import (
|
|||
serialize_application_history,
|
||||
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():
|
||||
|
|
@ -74,14 +74,58 @@ def test_history_reapplicant_needs_an_assigned_job():
|
|||
assert len(both["applications"]) == 2
|
||||
|
||||
|
||||
def test_current_inbox_row_matches_message_pk():
|
||||
item = {"source": "inbox", "message_id": "11111111-1111-1111-1111-111111111111"}
|
||||
payload = {"id": "11111111-1111-1111-1111-111111111111", "email": "a@x.com"}
|
||||
def test_clicked_inbox_row_is_not_a_previous_application():
|
||||
"""List payloads use `source` for the To address, not 'inbox'."""
|
||||
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, {"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():
|
||||
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
|
||||
|
||||
|
||||
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 { 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 = {
|
||||
inbox: 'Email',
|
||||
|
|
@ -49,9 +50,66 @@ export function applicationStatusLabel(status, item) {
|
|||
|
||||
export function previousApplicationsOf(row) {
|
||||
if (!row) return []
|
||||
if (Array.isArray(row.previousApplications)) return row.previousApplications
|
||||
if (Array.isArray(row.previous_applications)) return row.previous_applications
|
||||
return []
|
||||
const raw = Array.isArray(row.previousApplications)
|
||||
? row.previousApplications
|
||||
: 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) {
|
||||
|
|
@ -62,9 +120,7 @@ function hasAssignedJob(item) {
|
|||
|
||||
export function isReapplicant(row) {
|
||||
if (!row) return false
|
||||
const previous = previousApplicationsOf(row)
|
||||
if (previous.length) return previous.some(hasAssignedJob)
|
||||
return row.isReapplicant === true || row.is_reapplicant === true
|
||||
return previousApplicationsOf(row).some(hasAssignedJob)
|
||||
}
|
||||
|
||||
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. */
|
||||
export function PreviousApplications({ row, title = 'Previous applications' }) {
|
||||
const items = previousApplicationsOf(row)
|
||||
|
|
@ -120,6 +193,8 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
|
|||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, idx) => {
|
||||
const stage = applicationStatusLabel(item.status, item)
|
||||
const job = item.job_title || item.jobTitle || 'No job assigned'
|
||||
const href = hrefForPreviousApplication(item)
|
||||
const key = [
|
||||
item.source,
|
||||
item.inbox_id,
|
||||
|
|
@ -132,16 +207,25 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
|
|||
return (
|
||||
<div
|
||||
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' }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="fw-600" style={{ fontSize: 13 }}>
|
||||
{item.job_title || item.jobTitle || 'No job assigned'}
|
||||
</div>
|
||||
{href ? (
|
||||
<Link
|
||||
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">
|
||||
{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>
|
||||
<Badge className={STAGE_BADGE[stage] || 'b-gray'}>{stage}</Badge>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
============================================================ */
|
||||
|
||||
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 Modal from '../ui/Modal'
|
||||
|
|
@ -159,6 +159,10 @@ function parseGraphDate(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
|
||||
* lands — careers-rozee@, employee-referral@, mustakbil@ and so on. Strip
|
||||
|
|
@ -418,6 +422,7 @@ async function fetchMessageDetail(recordId) {
|
|||
return {
|
||||
kind: 'email',
|
||||
id: String(row.id),
|
||||
message_id: row.message_id || null,
|
||||
name,
|
||||
initials: initialsOf(name),
|
||||
color: avatarColor(name),
|
||||
|
|
@ -474,6 +479,7 @@ async function fetchApplications(params) {
|
|||
return {
|
||||
kind: 'email',
|
||||
id: String(row.id),
|
||||
message_id: row.message_id || null,
|
||||
name,
|
||||
initials: initialsOf(name),
|
||||
color: avatarColor(name),
|
||||
|
|
@ -949,6 +955,7 @@ function QueueFreshness({ at, refreshing, onRefresh }) {
|
|||
export default function Inbox() {
|
||||
const { toast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const qc = useQueryClient()
|
||||
const { can } = useAuth()
|
||||
const canEdit = can('inbox.edit')
|
||||
|
|
@ -964,6 +971,7 @@ export default function Inbox() {
|
|||
const [q, setQ] = useState('')
|
||||
const [assigning, setAssigning] = 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
|
||||
// pauses. Untyped, each character produced a fresh key, an in-flight request
|
||||
|
|
@ -986,6 +994,21 @@ export default function Inbox() {
|
|||
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'
|
||||
// Combined channel: both sources fetched UNPAGED (each endpoint reads a
|
||||
// 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
|
||||
|
||||
// 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')
|
||||
const detailQuery = useQuery({
|
||||
queryKey: selectedKind === 'form' ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId),
|
||||
|
|
@ -1216,9 +1240,13 @@ export default function Inbox() {
|
|||
))
|
||||
}, [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
|
||||
? { ...selectedRow, ...(detailQuery.data ?? {}) }
|
||||
? {
|
||||
...selectedRow,
|
||||
...(detailQuery.data ?? {}),
|
||||
received: selectedRow?.received ?? detailQuery.data?.received ?? null,
|
||||
}
|
||||
: null
|
||||
|
||||
// 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)
|
||||
setSkip(0)
|
||||
setSelectedId(null)
|
||||
setOpenKindHint(null)
|
||||
setQ('')
|
||||
setSearch('') // clear the committed term too, or the new channel's first
|
||||
// fetch carries the old channel's search for 300ms
|
||||
|
|
@ -1341,12 +1370,13 @@ export default function Inbox() {
|
|||
|
||||
function select(id) {
|
||||
setSelectedId(id)
|
||||
setOpenKindHint(null)
|
||||
// Phones swap the list for the detail pane — bring its top into view.
|
||||
if (window.matchMedia?.('(max-width: 900px)').matches) {
|
||||
window.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?.unread) setRead.mutate({ ids: [id], read: true })
|
||||
}
|
||||
|
|
@ -1645,7 +1675,7 @@ export default function Inbox() {
|
|||
sidebar.map((i) => (
|
||||
<div
|
||||
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)}
|
||||
>
|
||||
{i.kind !== 'form' && (
|
||||
|
|
|
|||
|
|
@ -1012,6 +1012,9 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.inbox-item:hover { background: var(--bg-sunken); }
|
||||
.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 .ii-name { font-weight: 700; }
|
||||
.ii-main { flex: 1; min-width: 0; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue