reapplied logic correct #76

Merged
ahmed.mujtaba merged 2 commits from SQS_BROKER into main 2026-09-07 10:56:41 +00:00
11 changed files with 370 additions and 80 deletions

36
.gitea/workflows/ci.yml Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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' && (

View File

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