candidate s3 lkinked

pull/30/head
ahmed.mujtaba 2026-08-28 18:10:28 +05:00
parent 617e8bae43
commit fef9fa827b
9 changed files with 235 additions and 54 deletions

View File

@ -5,7 +5,13 @@ never overwritten, so the plain `alembic` CLI works alongside `db_setup.init_db(
Models are discovered automatically: every `<package>/models.py` under `backend/` Models are discovered automatically: every `<package>/models.py` under `backend/`
is imported before the metadata is diffed against the live schema. is imported before the metadata is diffed against the live schema.
python alembic_setup.py [migrate|revision|upgrade|downgrade|current|head] [-m MSG] [-r REV] python alembic_setup.py [migrate|revision|makemigrations|upgrade|downgrade|current|head|stamp] [-m MSG] [-r REV]
Django-shaped aliases (same behaviour, different names):
python alembic_setup.py makemigrations -m "add form_data"
python alembic_setup.py upgrade # apply versions/*.py (like migrate)
python alembic_setup.py stamp -r f3a7e5b34c86 # bookmark only; no DDL
Named `alembic_setup` rather than `alembic` because `backend/` is on `sys.path`, Named `alembic_setup` rather than `alembic` because `backend/` is on `sys.path`,
so a module called `alembic.py` would shadow the installed package. so a module called `alembic.py` would shadow the installed package.
@ -28,6 +34,7 @@ from alembic.config import Config
from alembic.operations import Operations from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory from alembic.script import ScriptDirectory
from alembic.script.revision import ResolutionError
from sqlalchemy import MetaData, text from sqlalchemy import MetaData, text
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
@ -243,10 +250,30 @@ async def current() -> str | None:
MODELS_STAMP = "models" # alembic_version marker when no revision files ship in the image MODELS_STAMP = "models" # alembic_version marker when no revision files ship in the image
def _revision_on_disk(revision_id: str) -> bool:
"""True when `revision_id` exists under migrations/versions (or is a known alias)."""
try:
ScriptDirectory.from_config(config()).get_revision(revision_id)
except ResolutionError:
return False
return True
async def upgrade(revision: str = "head") -> None: async def upgrade(revision: str = "head") -> None:
if revision == "head" and not head(): if revision == "head" and not head():
logger.info("no alembic revisions on disk; skipping upgrade") logger.info("no alembic revisions on disk; skipping upgrade")
return return
# versions/*.py are gitignored, so each machine (and RDS) can stamp an id
# this checkout has never seen. Alembic then dies with ResolutionError
# before any DDL. Skip rather than crash; apply_model_drift still runs
# when DB_AUTOGENERATE is on.
current_rev = await current()
if current_rev and not _revision_on_disk(current_rev):
logger.warning(
"database revision %s is not in migrations/versions/; skipping alembic upgrade",
current_rev,
)
return
await _run(lambda c: command.upgrade(config(c), revision)) await _run(lambda c: command.upgrade(config(c), revision))
logger.info("upgraded to %s", revision) logger.info("upgraded to %s", revision)
@ -437,7 +464,16 @@ def main(argv: Sequence[str] | None = None) -> None:
"command", "command",
nargs="?", nargs="?",
default="migrate", default="migrate",
choices=["migrate", "revision", "upgrade", "downgrade", "current", "head"], choices=[
"migrate",
"revision",
"makemigrations",
"upgrade",
"downgrade",
"current",
"head",
"stamp",
],
) )
parser.add_argument("-m", "--message", default="auto", help="revision message") parser.add_argument("-m", "--message", default="auto", help="revision message")
parser.add_argument("-r", "--revision", help="target revision") parser.add_argument("-r", "--revision", help="target revision")
@ -450,7 +486,7 @@ def main(argv: Sequence[str] | None = None) -> None:
try: try:
if args.command == "migrate": if args.command == "migrate":
await init_db() await init_db()
elif args.command == "revision": elif args.command in ("revision", "makemigrations"):
print(await autogenerate(args.message) or "no changes") print(await autogenerate(args.message) or "no changes")
elif args.command == "upgrade": elif args.command == "upgrade":
await upgrade(args.revision or "head") await upgrade(args.revision or "head")
@ -460,6 +496,8 @@ def main(argv: Sequence[str] | None = None) -> None:
print(await current()) print(await current())
elif args.command == "head": elif args.command == "head":
print(head()) print(head())
elif args.command == "stamp":
await stamp(args.revision or "head")
finally: finally:
await close_db() await close_db()

View File

@ -2,7 +2,7 @@
"account": "", "account": "",
"client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com", "client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com",
"client_secret": "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", "client_secret": "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",
"refresh_token": "1//038IfgCu3D42fCgYIARAAGAMSNwF-L9IrYAZ_DJUqwC9ETwLtH23D46j61gWMwFQjRWPklFZIiLmv7Q3-TOgcNxTrjO30jgkeYOo", "refresh_token": "1//03C8LMkk-9mSyCgYIARAAGAMSNwF-L9IrAcPhShsp3cDeprSlqI-P6lArpmbyzu-PcKiLfQ5gC3H_MEk930IaKfewy3cxP3T0Oo8",
"type": "authorized_user", "type": "authorized_user",
"universe_domain": "googleapis.com" "universe_domain": "googleapis.com"
} }

View File

@ -27,21 +27,43 @@ export function isS3Ref(value) {
return /^(Email|Manual|Form)\//i.test(raw) return /^(Email|Manual|Form)\//i.test(raw)
} }
/** Presignable S3 ref, or an external http link (Drive / Sheet). Local disk paths are not. */
export function canOpen(filePath) {
const key = firstKey(filePath)
if (!key) return false
return isS3Ref(key) || /^https?:\/\//i.test(key)
}
/** First usable S3/http ref on a candidate or inbox payload. */
export function resumeKeyFrom(item) {
if (!item) return null
const fromFiles = (item.files || []).map((f) => f.url).find(Boolean)
if (fromFiles) return firstKey(fromFiles)
const fromDocs = (item.documents || []).map((d) => d.path).find(Boolean)
if (fromDocs) return firstKey(fromDocs)
return firstKey(item.file_path || item.filePath)
}
/** /**
* Fresh presign on every click. The signed URL opens in a new tab so the * Fresh presign on every click. The signed URL opens in a new tab so the
* browser's built-in PDF viewer renders it. Non-S3 http (Drive / Sheet links) * browser's built-in PDF viewer renders it. Non-S3 http (Drive / Sheet links)
* open as-is. Pass `tab` from a synchronous window.open to beat the pop-up blocker. * open as-is. Pass `tab` from a synchronous window.open to beat the pop-up blocker.
*
* Leftover local paths (`app/inbox/decoded_attachments/...`) are not sent to
* S3 that produced NoSuchKey. Only Email|Manual|Form keys and S3 URLs presign.
*/ */
export async function openPdf(filePath, { tab } = {}) { export async function openPdf(filePath, { tab } = {}) {
const key = firstKey(filePath) const key = firstKey(filePath)
if (!key) throw new Error('No resume file on this application') if (!key) throw new Error('No resume file on this application')
let url let url
if (isS3Ref(key) || !/^https?:\/\//i.test(key)) { if (isS3Ref(key)) {
const res = await openUrl(key) const res = await openUrl(key)
url = res?.data?.url url = res?.data?.url
if (!url) throw new Error('Could not open resume') if (!url) throw new Error('Could not open resume')
} else { } else if (/^https?:\/\//i.test(key)) {
url = key url = key
} else {
throw new Error('This resume is not stored in S3')
} }
if (tab && !tab.closed) tab.location.replace(url) if (tab && !tab.closed) tab.location.replace(url)
else { else {

View File

@ -25,6 +25,7 @@ import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import OpenResumeButton from '../ui/OpenResumeButton'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
@ -35,6 +36,7 @@ import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates' import * as candidatesApi from '../api/candidates'
import * as formsApi from '../api/forms' import * as formsApi from '../api/forms'
import * as pipelineApi from '../api/pipeline' import * as pipelineApi from '../api/pipeline'
import * as s3Api from '../api/s3'
import CandidateFormsTab from './CandidateForms' import CandidateFormsTab from './CandidateForms'
import { companies, fmtDate, moneyK, pick } from '../data/seed' import { companies, fmtDate, moneyK, pick } from '../data/seed'
@ -143,6 +145,7 @@ export default function CandidateProfile({
// flattens every application the candidate owns; writes land on the first, // flattens every application the candidate owns; writes land on the first,
// which is the one the header is describing. // which is the one the header is describing.
const inboxId = live?.inbox_id ?? null const inboxId = live?.inbox_id ?? null
const resumeKey = s3Api.resumeKeyFrom(live)
// Same key as the Forms tab's own query, so the tab count and the tab body // Same key as the Forms tab's own query, so the tab count and the tab body
// share one fetch. Fetching is not stage-gated (only creating is). Manual // share one fetch. Fetching is not stage-gated (only creating is). Manual
@ -300,16 +303,20 @@ export default function CandidateProfile({
{(live?.source || c.source) && <Badge className="b-gray">{live?.source || c.source}</Badge>} {(live?.source || c.source) && <Badge className="b-gray">{live?.source || c.source}</Badge>}
{expChip && <span className="badge b-plain b-indigo badge-plain">{expChip}</span>} {expChip && <span className="badge b-plain b-indigo badge-plain">{expChip}</span>}
</div> </div>
{live?.linkedin_url && ( {(s3Api.canOpen(resumeKey) || live?.linkedin_url) && (
<a <div className="flex gap-8" style={{ flexWrap: 'wrap', marginTop: 10 }}>
className="btn btn-secondary btn-sm" <OpenResumeButton filePath={resumeKey} />
href={live.linkedin_url} {live?.linkedin_url && (
target="_blank" <a
rel="noopener noreferrer" className="btn btn-secondary btn-sm"
style={{ marginTop: 10 }} href={live.linkedin_url}
> target="_blank"
<Icon name="linkedin" /> LinkedIn rel="noopener noreferrer"
</a> >
<Icon name="linkedin" /> LinkedIn
</a>
)}
</div>
)} )}
</div> </div>
{/* No score anywhere -> the whole block goes, rather than a ring drawn {/* No score anywhere -> the whole block goes, rather than a ring drawn
@ -574,7 +581,11 @@ export default function CandidateProfile({
)))} )))}
{tab === 'Documents' && (guard || (live ? ( {tab === 'Documents' && (guard || (live ? (
<DocumentsTab rows={live.documents ?? []} inboxId={inboxId} /> <DocumentsTab
rows={live.documents ?? []}
inboxId={inboxId}
manualUploadCandidateId={live.manual_upload_candidate_id}
/>
) : ( ) : (
<div className="list-tight"> <div className="list-tight">
{[ {[
@ -661,13 +672,21 @@ export default function CandidateProfile({
function ResumeTab({ live }) { function ResumeTab({ live }) {
const source = live.documents?.[0]?.name const source = live.documents?.[0]?.name
const resumeKey = s3Api.resumeKeyFrom(live)
if (!live.resume_text) { if (!live.resume_text) {
return ( return (
<EmptyState icon="file" title="No résumé text"> <>
{source {s3Api.canOpen(resumeKey) && (
? `${source} is attached but has not been parsed yet — run the match to extract it.` <div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 14 }}>
: 'This candidate applied without an attachment we could read.'} <OpenResumeButton filePath={resumeKey} />
</EmptyState> </div>
)}
<EmptyState icon="file" title="No résumé text">
{source
? `${source} is attached but has not been parsed yet — run the match to extract it.`
: 'This candidate applied without an attachment we could read.'}
</EmptyState>
</>
) )
} }
return ( return (
@ -677,6 +696,11 @@ function ResumeTab({ live }) {
<p className="text-muted"> <p className="text-muted">
{source ? `Extracted from ${source}` : 'Extracted from the application email'} {source ? `Extracted from ${source}` : 'Extracted from the application email'}
</p> </p>
{s3Api.canOpen(resumeKey) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', margin: '10px 0 4px' }}>
<OpenResumeButton filePath={resumeKey} />
</div>
)}
<div className="divider" /> <div className="divider" />
<div className="text-sm" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{live.resume_text}</div> <div className="text-sm" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{live.resume_text}</div>
</div> </div>
@ -1173,11 +1197,12 @@ function ActivityTab({ userId, inboxId, rows }) {
) )
} }
function DocumentsTab({ rows, inboxId }) { function DocumentsTab({ rows, inboxId, manualUploadCandidateId }) {
const { toast } = useToast() const { toast } = useToast()
const download = useMutation({ const download = useMutation({
mutationFn: ({ index, filename }) => candidatesApi.downloadDocument({ mutationFn: ({ index, filename }) => candidatesApi.downloadDocument({
inboxId, inboxId,
manualUploadCandidateId,
index, index,
filename, filename,
}), }),
@ -1187,28 +1212,42 @@ function DocumentsTab({ rows, inboxId }) {
if (!rows.length) { if (!rows.length) {
return <EmptyState icon="file" title="No documents">This application arrived without attachments.</EmptyState> return <EmptyState icon="file" title="No documents">This application arrived without attachments.</EmptyState>
} }
const canDownload = Boolean(inboxId || manualUploadCandidateId)
return ( return (
<div className="list-tight"> <div className="list-tight">
{rows.map((d, i) => ( {rows.map((d, i) => {
<div className="list-row" key={`${d.name}-${i}`}> const path = d.path || ''
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}> const openable = s3Api.canOpen(path)
<Icon name="file" /> return (
</span> <div className="list-row" key={`${d.name}-${i}`}>
<div className="lr-main"> <span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
<div className="lr-title">{d.name}</div> <Icon name="file" />
<div className="lr-sub">Stored with the application</div> </span>
<div className="lr-main">
<div className="lr-title">{d.name}</div>
<div className="lr-sub">Stored with the application</div>
</div>
{openable ? (
<OpenResumeButton
filePath={path}
className="act-btn"
label=""
icon="eye"
/>
) : (
<button
className="act-btn"
disabled={!canDownload || download.isPending}
title={!canDownload ? 'No application id for download' : 'Download'}
aria-label={`Download ${d.name}`}
onClick={() => download.mutate({ index: i, filename: d.name })}
>
<Icon name="download" />
</button>
)}
</div> </div>
<button )
className="act-btn" })}
disabled={!inboxId || download.isPending}
title={!inboxId ? 'No application id for download' : 'Download'}
aria-label={`Download ${d.name}`}
onClick={() => download.mutate({ index: i, filename: d.name })}
>
<Icon name="download" />
</button>
</div>
))}
</div> </div>
) )
} }

View File

@ -11,6 +11,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import OpenResumeButton from '../ui/OpenResumeButton'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable' import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
@ -1355,9 +1356,13 @@ function FormApplicantDetail({
{(resumeHref || profileHref) && ( {(resumeHref || profileHref) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}> <div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
{resumeHref && ( {resumeHref && (
<a className="btn btn-primary btn-sm" href={resumeHref} target="_blank" rel="noopener noreferrer"> s3Api.canOpen(resumeHref) ? (
<Icon name="paperclip" /> Open resume <OpenResumeButton filePath={resumeHref} />
</a> ) : (
<a className="btn btn-primary btn-sm" href={resumeHref} target="_blank" rel="noopener noreferrer">
<Icon name="paperclip" /> Open resume
</a>
)
)} )}
{profileHref && ( {profileHref && (
<a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer"> <a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer">
@ -1655,7 +1660,7 @@ function ApplicationDetail({
const panelBusy = busy || assignMutation.isPending || rematchMutation.isPending const panelBusy = busy || assignMutation.isPending || rematchMutation.isPending
const canAssign = canEdit && selection && selection !== i.assignedId && !assignMutation.isPending const canAssign = canEdit && selection && selection !== i.assignedId && !assignMutation.isPending
const resumeKey = firstResumeKey(i) const resumeKey = firstResumeKey(i)
const canOpenResume = Boolean(resumeKey) const canOpenResume = s3Api.canOpen(resumeKey)
const profileHref = i.linkedinUrl || linkedinHrefFromSlug(i.linkedinSlug) const profileHref = i.linkedinUrl || linkedinHrefFromSlug(i.linkedinSlug)
const openResume = useMutation({ const openResume = useMutation({

View File

@ -53,6 +53,7 @@ export default function Managers() {
const managers = managersQuery.data?.rows ?? [] const managers = managersQuery.data?.rows ?? []
const total = managersQuery.data?.total ?? 0 const total = managersQuery.data?.total ?? 0
const jobs = jobsQuery.data ?? [] const jobs = jobsQuery.data ?? []
const totalReqs = jobs.filter((j) => j.status === 'Open').length
const pages = Math.max(1, Math.ceil(total / pageSize)) const pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(page, pages) const currentPage = Math.min(page, pages)

View File

@ -12,6 +12,7 @@ import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import OpenResumeButton from '../ui/OpenResumeButton'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
@ -22,6 +23,7 @@ import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox' import * as inboxApi from '../api/inbox'
import * as jobPostsApi from '../api/jobPosts' import * as jobPostsApi from '../api/jobPosts'
import * as s3Api from '../api/s3'
import { import {
avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta, avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta,
} from '../data/seed' } from '../data/seed'
@ -97,6 +99,7 @@ function mapApplication(row) {
processing: row.processing || 'Unread', processing: row.processing || 'Unread',
resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending', resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending',
resumeText: row.resume_text || '', resumeText: row.resume_text || '',
filePath: row.file_path || '',
suggestedIds: suggested.map(String), suggestedIds: suggested.map(String),
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
matchStatus: row.match_status || null, matchStatus: row.match_status || null,
@ -134,6 +137,8 @@ async function fetchDetail(recordId) {
// mail that never had markup. EmailBody sanitises before rendering. // mail that never had markup. EmailBody sanitises before rendering.
bodyHtml: row.body || '', bodyHtml: row.body || '',
resumeText: row.resume_text || '', resumeText: row.resume_text || '',
files: Array.isArray(row.files) ? row.files : [],
filePath: row.file_path || '',
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending', resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
processing: row.unread ? 'Unread' : 'Read', processing: row.unread ? 'Unread' : 'Read',
suggestedIds: (row.suggested_job_post_ids || []).map(String), suggestedIds: (row.suggested_job_post_ids || []).map(String),
@ -372,6 +377,7 @@ export default function Matching() {
} }
const resumeText = detail?.resumeText || listRow?.resumeText || '' const resumeText = detail?.resumeText || listRow?.resumeText || ''
const resumeKey = s3Api.resumeKeyFrom(detail) || s3Api.resumeKeyFrom(listRow)
const matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus) const matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus)
return ( return (
@ -468,6 +474,7 @@ export default function Matching() {
suggestionCards={suggestionCards} suggestionCards={suggestionCards}
selectedPost={selectedPost} selectedPost={selectedPost}
resumeText={resumeText} resumeText={resumeText}
resumeKey={resumeKey}
whyOpen={whyOpen} whyOpen={whyOpen}
setWhyOpen={setWhyOpen} setWhyOpen={setWhyOpen}
matchFailed={matchFailed} matchFailed={matchFailed}
@ -519,6 +526,7 @@ function MatchingWorkspace({
suggestionCards, suggestionCards,
selectedPost, selectedPost,
resumeText, resumeText,
resumeKey,
whyOpen, whyOpen,
setWhyOpen, setWhyOpen,
matchFailed, matchFailed,
@ -563,6 +571,12 @@ function MatchingWorkspace({
</div> </div>
</div> </div>
{s3Api.canOpen(resumeKey) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
<OpenResumeButton filePath={resumeKey} />
</div>
)}
{assigned && ( {assigned && (
<div <div
className="card" className="card"

View File

@ -15,6 +15,7 @@ import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import OpenResumeButton from '../ui/OpenResumeButton'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed' import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
@ -22,6 +23,7 @@ import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates' import * as candidatesApi from '../api/candidates'
import * as pipelineApi from '../api/pipeline' import * as pipelineApi from '../api/pipeline'
import * as s3Api from '../api/s3'
const TABS = ['Overview', 'Scoring', 'File'] const TABS = ['Overview', 'Scoring', 'File']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 } const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
@ -120,6 +122,7 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
const source = live?.source ?? c.source ?? null const source = live?.source ?? c.source ?? null
const filename = const filename =
live?.documents?.[0]?.name || c.filename || null live?.documents?.[0]?.name || c.filename || null
const filePath = s3Api.resumeKeyFrom(live) || c.filePath || null
const matchSummary = live?.match_summary ?? null const matchSummary = live?.match_summary ?? null
const messageId = live?.message_id ?? null const messageId = live?.message_id ?? null
// ats_results wins over the detail payload's denormalised copy, because it is // ats_results wins over the detail payload's denormalised copy, because it is
@ -144,6 +147,7 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
experience, experience,
source, source,
filename, filename,
filePath,
matchSummary, matchSummary,
messageId, messageId,
aiScore, aiScore,
@ -203,6 +207,11 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
<span className="badge b-plain b-indigo badge-plain">{view.experienceBadge}</span> <span className="badge b-plain b-indigo badge-plain">{view.experienceBadge}</span>
)} )}
</div> </div>
{s3Api.canOpen(view.filePath) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginTop: 10 }}>
<OpenResumeButton filePath={view.filePath} />
</div>
)}
</div> </div>
{view.aiScore != null && ( {view.aiScore != null && (
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
@ -249,14 +258,21 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
)} )}
{tab === 'File' && ( {tab === 'File' && (
<div className="info-grid"> <>
<div className="info-item"><div className="il">File Name</div><div className="iv">{view.filename ?? '—'}</div></div> <div className="info-grid">
<div className="info-item"><div className="il">Source</div><div className="iv">{view.sourceLabel}</div></div> <div className="info-item"><div className="il">File Name</div><div className="iv">{view.filename ?? '—'}</div></div>
<div className="info-item"><div className="il">Detail</div><div className="iv">{view.matchSummary ?? '—'}</div></div> <div className="info-item"><div className="il">Source</div><div className="iv">{view.sourceLabel}</div></div>
{view.errorCode && ( <div className="info-item"><div className="il">Detail</div><div className="iv">{view.matchSummary ?? '—'}</div></div>
<div className="info-item"><div className="il">Error</div><div className="iv">{view.errorCode}</div></div> {view.errorCode && (
<div className="info-item"><div className="il">Error</div><div className="iv">{view.errorCode}</div></div>
)}
</div>
{s3Api.canOpen(view.filePath) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginTop: 16 }}>
<OpenResumeButton filePath={view.filePath} />
</div>
)} )}
</div> </>
)} )}
</>))} </>))}
</div> </div>

View File

@ -0,0 +1,46 @@
import { useMutation } from '@tanstack/react-query'
import { friendlyAuthError } from '../lib/errors'
import * as s3Api from '../api/s3'
import { Icon } from './primitives'
import { useToast } from './Toast'
/**
* Same control Inbox uses: GET /s3/open on click, then open the short-lived
* URL in a new tab so the browser PDF viewer renders it.
*/
export default function OpenResumeButton({
filePath,
className = 'btn btn-primary btn-sm',
label = 'Open resume',
icon = 'paperclip',
}) {
const { toast } = useToast()
const key = s3Api.canOpen(filePath) ? s3Api.firstKey(filePath) : null
const openResume = useMutation({
mutationFn: async (tab) => {
try {
return await s3Api.openPdf(key, { tab })
} catch (err) {
if (tab && !tab.closed) tab.close()
throw err
}
},
onError: (err) => toast(friendlyAuthError(err, 'Could not open resume'), 'error'),
})
if (!key) return null
const text = label ? (openResume.isPending ? 'Opening…' : label) : null
return (
<button
type="button"
className={className}
disabled={openResume.isPending}
aria-label={label || 'Open resume'}
title={label || 'Open resume'}
onClick={() => openResume.mutate(window.open('about:blank', '_blank'))}
>
<Icon name={icon} />{text ? ` ${text}` : null}
</button>
)
}