diff --git a/backend/alembic_setup.py b/backend/alembic_setup.py index 4d9ec35..bce75d1 100644 --- a/backend/alembic_setup.py +++ b/backend/alembic_setup.py @@ -5,7 +5,13 @@ never overwritten, so the plain `alembic` CLI works alongside `db_setup.init_db( Models are discovered automatically: every `/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() diff --git a/backend/credentials/application_default_credentials.json b/backend/credentials/application_default_credentials.json index 7c33d3a..776c982 100644 --- a/backend/credentials/application_default_credentials.json +++ b/backend/credentials/application_default_credentials.json @@ -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" -} \ No newline at end of file +} diff --git a/frontend/src/api/s3.js b/frontend/src/api/s3.js index 2a31b88..1522725 100644 --- a/frontend/src/api/s3.js +++ b/frontend/src/api/s3.js @@ -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 { diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 534bec4..a690fd9 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -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) && {live?.source || c.source}} {expChip && {expChip}} - {live?.linkedin_url && ( - - LinkedIn - + {(s3Api.canOpen(resumeKey) || live?.linkedin_url) && ( +
+ + {live?.linkedin_url && ( + + LinkedIn + + )} +
)} {/* No score anywhere -> the whole block goes, rather than a ring drawn @@ -574,7 +581,11 @@ export default function CandidateProfile({ )))} {tab === 'Documents' && (guard || (live ? ( - + ) : (
{[ @@ -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 ( - - {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.'} - + <> + {s3Api.canOpen(resumeKey) && ( +
+ +
+ )} + + {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.'} + + ) } return ( @@ -677,6 +696,11 @@ function ResumeTab({ live }) {

{source ? `Extracted from ${source}` : 'Extracted from the application email'}

+ {s3Api.canOpen(resumeKey) && ( +
+ +
+ )}
{live.resume_text}
@@ -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 This application arrived without attachments. } + const canDownload = Boolean(inboxId || manualUploadCandidateId) return (
- {rows.map((d, i) => ( -
- - - -
-
{d.name}
-
Stored with the application
+ {rows.map((d, i) => { + const path = d.path || '' + const openable = s3Api.canOpen(path) + return ( +
+ + + +
+
{d.name}
+
Stored with the application
+
+ {openable ? ( + + ) : ( + + )}
- -
- ))} + ) + })}
) } diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 51ba789..efed63d 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -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) && (
{resumeHref && ( - - Open resume - + s3Api.canOpen(resumeHref) ? ( + + ) : ( + + Open resume + + ) )} {profileHref && ( @@ -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({ diff --git a/frontend/src/screens/Managers.jsx b/frontend/src/screens/Managers.jsx index 4714923..c46d068 100644 --- a/frontend/src/screens/Managers.jsx +++ b/frontend/src/screens/Managers.jsx @@ -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) diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx index 3eb52cf..1d8422d 100644 --- a/frontend/src/screens/Matching.jsx +++ b/frontend/src/screens/Matching.jsx @@ -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({
+ {s3Api.canOpen(resumeKey) && ( +
+ +
+ )} + {assigned && (
{view.experienceBadge} )}
+ {s3Api.canOpen(view.filePath) && ( +
+ +
+ )}
{view.aiScore != null && (
@@ -249,14 +258,21 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose )} {tab === 'File' && ( -
-
File Name
{view.filename ?? '—'}
-
Source
{view.sourceLabel}
-
Detail
{view.matchSummary ?? '—'}
- {view.errorCode && ( -
Error
{view.errorCode}
+ <> +
+
File Name
{view.filename ?? '—'}
+
Source
{view.sourceLabel}
+
Detail
{view.matchSummary ?? '—'}
+ {view.errorCode && ( +
Error
{view.errorCode}
+ )} +
+ {s3Api.canOpen(view.filePath) && ( +
+ +
)} -
+ )} ))}
diff --git a/frontend/src/ui/OpenResumeButton.jsx b/frontend/src/ui/OpenResumeButton.jsx new file mode 100644 index 0000000..f1bfe98 --- /dev/null +++ b/frontend/src/ui/OpenResumeButton.jsx @@ -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 ( + + ) +}