Merge pull request 'candidate s3 lkinked' (#30) from RELIMIt into main
Deploy to S3 / deploy (push) Successful in 37s Details

Reviewed-on: #30
pull/31/head
ahmed.mujtaba 2026-08-28 13:16:09 +00:00
commit 60438cf271
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/`
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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>
)
}