Merge pull request 'candidate s3 lkinked' (#30) from RELIMIt into main
Deploy to S3 / deploy (push) Successful in 37s
Details
Deploy to S3 / deploy (push) Successful in 37s
Details
Reviewed-on: #30pull/31/head
commit
60438cf271
|
|
@ -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/`
|
||||
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`,
|
||||
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.runtime.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
from alembic.script.revision import ResolutionError
|
||||
from sqlalchemy import MetaData, text
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
if revision == "head" and not head():
|
||||
logger.info("no alembic revisions on disk; skipping upgrade")
|
||||
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))
|
||||
logger.info("upgraded to %s", revision)
|
||||
|
||||
|
|
@ -437,7 +464,16 @@ def main(argv: Sequence[str] | None = None) -> None:
|
|||
"command",
|
||||
nargs="?",
|
||||
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("-r", "--revision", help="target revision")
|
||||
|
|
@ -450,7 +486,7 @@ def main(argv: Sequence[str] | None = None) -> None:
|
|||
try:
|
||||
if args.command == "migrate":
|
||||
await init_db()
|
||||
elif args.command == "revision":
|
||||
elif args.command in ("revision", "makemigrations"):
|
||||
print(await autogenerate(args.message) or "no changes")
|
||||
elif args.command == "upgrade":
|
||||
await upgrade(args.revision or "head")
|
||||
|
|
@ -460,6 +496,8 @@ def main(argv: Sequence[str] | None = None) -> None:
|
|||
print(await current())
|
||||
elif args.command == "head":
|
||||
print(head())
|
||||
elif args.command == "stamp":
|
||||
await stamp(args.revision or "head")
|
||||
finally:
|
||||
await close_db()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"account": "",
|
||||
"client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com",
|
||||
"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",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
|
|
@ -27,21 +27,43 @@ export function isS3Ref(value) {
|
|||
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
|
||||
* 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.
|
||||
*
|
||||
* 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 } = {}) {
|
||||
const key = firstKey(filePath)
|
||||
if (!key) throw new Error('No resume file on this application')
|
||||
let url
|
||||
if (isS3Ref(key) || !/^https?:\/\//i.test(key)) {
|
||||
if (isS3Ref(key)) {
|
||||
const res = await openUrl(key)
|
||||
url = res?.data?.url
|
||||
if (!url) throw new Error('Could not open resume')
|
||||
} else {
|
||||
} else if (/^https?:\/\//i.test(key)) {
|
||||
url = key
|
||||
} else {
|
||||
throw new Error('This resume is not stored in S3')
|
||||
}
|
||||
if (tab && !tab.closed) tab.location.replace(url)
|
||||
else {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useMemo, useState } from 'react'
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import OpenResumeButton from '../ui/OpenResumeButton'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
|
|
@ -35,6 +36,7 @@ import { friendlyAuthError } from '../lib/errors'
|
|||
import * as candidatesApi from '../api/candidates'
|
||||
import * as formsApi from '../api/forms'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import * as s3Api from '../api/s3'
|
||||
import CandidateFormsTab from './CandidateForms'
|
||||
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,
|
||||
// which is the one the header is describing.
|
||||
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
|
||||
// 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>}
|
||||
{expChip && <span className="badge b-plain b-indigo badge-plain">{expChip}</span>}
|
||||
</div>
|
||||
{live?.linkedin_url && (
|
||||
<a
|
||||
className="btn btn-secondary btn-sm"
|
||||
href={live.linkedin_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginTop: 10 }}
|
||||
>
|
||||
<Icon name="linkedin" /> LinkedIn
|
||||
</a>
|
||||
{(s3Api.canOpen(resumeKey) || live?.linkedin_url) && (
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginTop: 10 }}>
|
||||
<OpenResumeButton filePath={resumeKey} />
|
||||
{live?.linkedin_url && (
|
||||
<a
|
||||
className="btn btn-secondary btn-sm"
|
||||
href={live.linkedin_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon name="linkedin" /> LinkedIn
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* No score anywhere -> the whole block goes, rather than a ring drawn
|
||||
|
|
@ -574,7 +581,11 @@ export default function CandidateProfile({
|
|||
)))}
|
||||
|
||||
{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">
|
||||
{[
|
||||
|
|
@ -661,13 +672,21 @@ export default function CandidateProfile({
|
|||
|
||||
function ResumeTab({ live }) {
|
||||
const source = live.documents?.[0]?.name
|
||||
const resumeKey = s3Api.resumeKeyFrom(live)
|
||||
if (!live.resume_text) {
|
||||
return (
|
||||
<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>
|
||||
<>
|
||||
{s3Api.canOpen(resumeKey) && (
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 14 }}>
|
||||
<OpenResumeButton filePath={resumeKey} />
|
||||
</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 (
|
||||
|
|
@ -677,6 +696,11 @@ function ResumeTab({ live }) {
|
|||
<p className="text-muted">
|
||||
{source ? `Extracted from ${source}` : 'Extracted from the application email'}
|
||||
</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="text-sm" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{live.resume_text}</div>
|
||||
</div>
|
||||
|
|
@ -1173,11 +1197,12 @@ function ActivityTab({ userId, inboxId, rows }) {
|
|||
)
|
||||
}
|
||||
|
||||
function DocumentsTab({ rows, inboxId }) {
|
||||
function DocumentsTab({ rows, inboxId, manualUploadCandidateId }) {
|
||||
const { toast } = useToast()
|
||||
const download = useMutation({
|
||||
mutationFn: ({ index, filename }) => candidatesApi.downloadDocument({
|
||||
inboxId,
|
||||
manualUploadCandidateId,
|
||||
index,
|
||||
filename,
|
||||
}),
|
||||
|
|
@ -1187,28 +1212,42 @@ function DocumentsTab({ rows, inboxId }) {
|
|||
if (!rows.length) {
|
||||
return <EmptyState icon="file" title="No documents">This application arrived without attachments.</EmptyState>
|
||||
}
|
||||
const canDownload = Boolean(inboxId || manualUploadCandidateId)
|
||||
return (
|
||||
<div className="list-tight">
|
||||
{rows.map((d, i) => (
|
||||
<div className="list-row" key={`${d.name}-${i}`}>
|
||||
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||
<Icon name="file" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{d.name}</div>
|
||||
<div className="lr-sub">Stored with the application</div>
|
||||
{rows.map((d, i) => {
|
||||
const path = d.path || ''
|
||||
const openable = s3Api.canOpen(path)
|
||||
return (
|
||||
<div className="list-row" key={`${d.name}-${i}`}>
|
||||
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||
<Icon name="file" />
|
||||
</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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||
|
||||
import Modal from '../ui/Modal'
|
||||
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
|
||||
import OpenResumeButton from '../ui/OpenResumeButton'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
|
||||
|
|
@ -1355,9 +1356,13 @@ function FormApplicantDetail({
|
|||
{(resumeHref || profileHref) && (
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
{resumeHref && (
|
||||
<a className="btn btn-primary btn-sm" href={resumeHref} target="_blank" rel="noopener noreferrer">
|
||||
<Icon name="paperclip" /> Open resume
|
||||
</a>
|
||||
s3Api.canOpen(resumeHref) ? (
|
||||
<OpenResumeButton filePath={resumeHref} />
|
||||
) : (
|
||||
<a className="btn btn-primary btn-sm" href={resumeHref} target="_blank" rel="noopener noreferrer">
|
||||
<Icon name="paperclip" /> Open resume
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
{profileHref && (
|
||||
<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 canAssign = canEdit && selection && selection !== i.assignedId && !assignMutation.isPending
|
||||
const resumeKey = firstResumeKey(i)
|
||||
const canOpenResume = Boolean(resumeKey)
|
||||
const canOpenResume = s3Api.canOpen(resumeKey)
|
||||
const profileHref = i.linkedinUrl || linkedinHrefFromSlug(i.linkedinSlug)
|
||||
|
||||
const openResume = useMutation({
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export default function Managers() {
|
|||
const managers = managersQuery.data?.rows ?? []
|
||||
const total = managersQuery.data?.total ?? 0
|
||||
const jobs = jobsQuery.data ?? []
|
||||
const totalReqs = jobs.filter((j) => j.status === 'Open').length
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const currentPage = Math.min(page, pages)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useSearchParams } from 'react-router-dom'
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
|
||||
import OpenResumeButton from '../ui/OpenResumeButton'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
|
||||
|
|
@ -22,6 +23,7 @@ import { qk } from '../lib/queryKeys'
|
|||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as s3Api from '../api/s3'
|
||||
import {
|
||||
avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta,
|
||||
} from '../data/seed'
|
||||
|
|
@ -97,6 +99,7 @@ function mapApplication(row) {
|
|||
processing: row.processing || 'Unread',
|
||||
resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending',
|
||||
resumeText: row.resume_text || '',
|
||||
filePath: row.file_path || '',
|
||||
suggestedIds: suggested.map(String),
|
||||
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
||||
matchStatus: row.match_status || null,
|
||||
|
|
@ -134,6 +137,8 @@ async function fetchDetail(recordId) {
|
|||
// mail that never had markup. EmailBody sanitises before rendering.
|
||||
bodyHtml: row.body || '',
|
||||
resumeText: row.resume_text || '',
|
||||
files: Array.isArray(row.files) ? row.files : [],
|
||||
filePath: row.file_path || '',
|
||||
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
|
||||
processing: row.unread ? 'Unread' : 'Read',
|
||||
suggestedIds: (row.suggested_job_post_ids || []).map(String),
|
||||
|
|
@ -372,6 +377,7 @@ export default function Matching() {
|
|||
}
|
||||
|
||||
const resumeText = detail?.resumeText || listRow?.resumeText || ''
|
||||
const resumeKey = s3Api.resumeKeyFrom(detail) || s3Api.resumeKeyFrom(listRow)
|
||||
const matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus)
|
||||
|
||||
return (
|
||||
|
|
@ -468,6 +474,7 @@ export default function Matching() {
|
|||
suggestionCards={suggestionCards}
|
||||
selectedPost={selectedPost}
|
||||
resumeText={resumeText}
|
||||
resumeKey={resumeKey}
|
||||
whyOpen={whyOpen}
|
||||
setWhyOpen={setWhyOpen}
|
||||
matchFailed={matchFailed}
|
||||
|
|
@ -519,6 +526,7 @@ function MatchingWorkspace({
|
|||
suggestionCards,
|
||||
selectedPost,
|
||||
resumeText,
|
||||
resumeKey,
|
||||
whyOpen,
|
||||
setWhyOpen,
|
||||
matchFailed,
|
||||
|
|
@ -563,6 +571,12 @@ function MatchingWorkspace({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{s3Api.canOpen(resumeKey) && (
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
<OpenResumeButton filePath={resumeKey} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assigned && (
|
||||
<div
|
||||
className="card"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { useMemo, useState } from 'react'
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import OpenResumeButton from '../ui/OpenResumeButton'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
|
||||
|
|
@ -22,6 +23,7 @@ import { qk } from '../lib/queryKeys'
|
|||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import * as s3Api from '../api/s3'
|
||||
|
||||
const TABS = ['Overview', 'Scoring', 'File']
|
||||
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 filename =
|
||||
live?.documents?.[0]?.name || c.filename || null
|
||||
const filePath = s3Api.resumeKeyFrom(live) || c.filePath || null
|
||||
const matchSummary = live?.match_summary ?? null
|
||||
const messageId = live?.message_id ?? null
|
||||
// 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,
|
||||
source,
|
||||
filename,
|
||||
filePath,
|
||||
matchSummary,
|
||||
messageId,
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
{s3Api.canOpen(view.filePath) && (
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginTop: 10 }}>
|
||||
<OpenResumeButton filePath={view.filePath} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{view.aiScore != null && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
|
|
@ -249,14 +258,21 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
|
|||
)}
|
||||
|
||||
{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-item"><div className="il">Source</div><div className="iv">{view.sourceLabel}</div></div>
|
||||
<div className="info-item"><div className="il">Detail</div><div className="iv">{view.matchSummary ?? '—'}</div></div>
|
||||
{view.errorCode && (
|
||||
<div className="info-item"><div className="il">Error</div><div className="iv">{view.errorCode}</div></div>
|
||||
<>
|
||||
<div className="info-grid">
|
||||
<div className="info-item"><div className="il">File Name</div><div className="iv">{view.filename ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Source</div><div className="iv">{view.sourceLabel}</div></div>
|
||||
<div className="info-item"><div className="il">Detail</div><div className="iv">{view.matchSummary ?? '—'}</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue