candidate s3 lkinked
parent
617e8bae43
commit
fef9fa827b
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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