From 7ceecdb6c78eb54f74f71062b6c4281d02cd7b75 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 7 Sep 2026 15:54:47 +0500 Subject: [PATCH] reapplied logic correct --- .gitea/workflows/ci.yml | 36 ++++++ .gitea/workflows/deploy-to-s3.yml | 80 ++++++++---- backend/inbox/models.py | 2 + backend/inbox/serializers.py | 2 + backend/job/candidate/models.py | 1 + backend/job/candidate/serializers.py | 2 + backend/job/candidate/views.py | 118 ++++++++++++------ backend/tests/test_application_history.py | 56 ++++++++- .../src/components/ReapplicantHistory.jsx | 108 ++++++++++++++-- frontend/src/screens/Inbox.jsx | 42 ++++++- frontend/src/styles/styles.css | 3 + 11 files changed, 370 insertions(+), 80 deletions(-) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..7db53b6 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitea/workflows/deploy-to-s3.yml b/.gitea/workflows/deploy-to-s3.yml index 76500c8..c5883f0 100644 --- a/.gitea/workflows/deploy-to-s3.yml +++ b/.gitea/workflows/deploy-to-s3.yml @@ -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 diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 017bd3d..c27dba4 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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, diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 61c43d7..cde7ac2 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -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, diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index f7a2af9..d4027aa 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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", diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index e50d0d9..ab26153 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -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, diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index ee17be6..5af8097 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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 { + 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' }) {
{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 (
-
- {item.job_title || item.jobTitle || 'No job assigned'} -
+ {href ? ( + e.stopPropagation()} + > + {job} + + ) : ( +
{job}
+ )}
{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)}` : ''}
{stage} diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index e4246c0..7074497 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -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) => (
select(i.id)} > {i.kind !== 'form' && ( diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index b2b4ea3..fdaadb5 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -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; } -- 2.40.1