From 1f4ec4d4512afa009d2a35c9011e849371f0f81f Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Fri, 11 Sep 2026 16:51:36 +0500 Subject: [PATCH] Stop candidate profiles crashing on production payload shapes. Co-authored-by: Cursor --- frontend/src/App.jsx | 18 +++++++++++++- frontend/src/api/s3.js | 22 ++++++++++++++--- frontend/src/components/ErrorBoundary.jsx | 5 ++++ frontend/src/screens/CandidateProfile.jsx | 10 ++++---- frontend/src/screens/CandidateWorkspace.jsx | 26 ++++++++++++++------- 5 files changed, 64 insertions(+), 17 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9c52297..3956eba 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import { lazy } from 'react' -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { BrowserRouter, Navigate, Route, Routes, useParams, useSearchParams } from 'react-router-dom' import AuthProvider from './auth/AuthProvider' import RequireAuth from './auth/RequireAuth' @@ -47,6 +47,14 @@ const SCREENS = { // Detail pages live outside the ROUTES table (no sidebar entry, parameterized path). const CandidatePage = lazy(() => import('./screens/CandidatePage')) +function LegacyCandidateRedirect() { + const { userId } = useParams() + const [params] = useSearchParams() + const tab = params.get('tab') + const to = `/candidate/${encodeURIComponent(userId)}` + return +} + export default function App() { return ( @@ -95,6 +103,14 @@ export default function App() { } /> + + + + } + /> } /> diff --git a/frontend/src/api/s3.js b/frontend/src/api/s3.js index d77043d..b0f0579 100644 --- a/frontend/src/api/s3.js +++ b/frontend/src/api/s3.js @@ -12,9 +12,17 @@ export function openUrl(key, { expiresIn } = {}) { }) } +function asList(value) { + return Array.isArray(value) ? value : [] +} + /** First comma-separated stored path — inbox_messages.file_path can list several. */ export function firstKey(filePath) { - return (filePath || '').split(',')[0].trim() || null + if (Array.isArray(filePath)) return firstKey(filePath[0]) + if (filePath && typeof filePath === 'object') { + return firstKey(filePath.url || filePath.path || filePath.key) + } + return String(filePath || '').split(',')[0].trim() || null } /** S3 object address (virtual-hosted URL) or record-scoped key Email|Manual|Form|Temp/... */ @@ -37,9 +45,17 @@ export function canOpen(filePath) { /** 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 (typeof item.files === 'string') { + const key = firstKey(item.files) + if (key) return key + } + const fromFiles = asList(item.files).map((f) => (typeof f === 'string' ? f : f?.url || f?.path)).find(Boolean) if (fromFiles) return firstKey(fromFiles) - const fromDocs = (item.documents || []).map((d) => d.path).find(Boolean) + if (typeof item.documents === 'string') { + const key = firstKey(item.documents) + if (key) return key + } + const fromDocs = asList(item.documents).map((d) => (typeof d === 'string' ? d : d?.path || d?.url)).find(Boolean) if (fromDocs) return firstKey(fromDocs) return firstKey(item.file_path || item.filePath) } diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx index 226b5b5..d33eebe 100644 --- a/frontend/src/components/ErrorBoundary.jsx +++ b/frontend/src/components/ErrorBoundary.jsx @@ -25,6 +25,11 @@ export default class ErrorBoundary extends Component { This screen hit an unexpected error. The rest of the app is fine — try again, or head back to the dashboard. + {this.state.error?.message ? ( +
+ {this.state.error.message} +
+ ) : null}
Skills
-
{c.skills.map((s) => {s})}
+
{(Array.isArray(c.skills) ? c.skills : []).map((s) => {s})}
)))} diff --git a/frontend/src/screens/CandidateWorkspace.jsx b/frontend/src/screens/CandidateWorkspace.jsx index ee9e964..e164ce2 100644 --- a/frontend/src/screens/CandidateWorkspace.jsx +++ b/frontend/src/screens/CandidateWorkspace.jsx @@ -17,7 +17,16 @@ const display = (value) => value === 0 ? '0' : value || '—' const experience = (value) => value == null || value === '' ? '—' : Number.isFinite(Number(value)) ? `${value} years` : value const profileLocation = (candidate) => candidate.location || candidate.city || candidate.candidate_city const appliedRole = (candidate) => candidate.job_title || candidate.assigned_job_post?.title || candidate.jobTitle -const profileSkills = (candidate) => [...new Set((candidate.skills?.length ? candidate.skills : candidate.matched_keywords || []).filter((skill) => typeof skill === 'string' && skill.trim()))] +function asList(value) { + if (Array.isArray(value)) return value.filter((item) => item != null) + if (typeof value === 'string' && value.trim()) return value.split(/[,;]/).map((item) => item.trim()).filter(Boolean) + return [] +} +const profileSkills = (candidate) => { + const skills = asList(candidate?.skills) + const keywords = asList(candidate?.matched_keywords) + return [...new Set((skills.length ? skills : keywords).filter((skill) => typeof skill === 'string' && skill.trim()))] +} function externalUrl(value) { try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : null } catch { return null } @@ -151,13 +160,14 @@ function Applications({ candidate, stage }) { function DocumentRow({ document, index, candidate, resume = false }) { const download = useDownload(candidate) - const name = document.name || 'Candidate document' + const path = typeof document === 'string' ? document : document?.path + const name = (typeof document === 'string' ? document : document?.name) || 'Candidate document' const ext = name.includes('.') ? name.split('.').pop().toUpperCase().slice(0, 4) : 'FILE' return
{ext}
{name}{ext}{resume && candidate.applied ? ` · ${fmtDate(candidate.applied)}` : ' · Attached document'}
- + {(candidate.inbox_id || candidate.manual_upload_candidate_id) && }
@@ -165,9 +175,9 @@ function DocumentRow({ document, index, candidate, resume = false }) { function RecentActivity({ candidate, onTab }) { const events = [ - ...(candidate.activity || []).map((item) => ({ title: item.activity_type || 'Activity recorded', detail: item.description || item.activity_status, date: item.activity_date, author: item.created_by_name })), - ...(candidate.notes || []).map((item) => ({ title: 'Internal note added', detail: item.note, date: item.created_at, author: item.created_by_name })), - ...(candidate.interviews || []).map((item) => ({ title: item.interview_type || 'Interview', detail: item.interview_status, date: item.interview_date })), + ...asList(candidate.activity).map((item) => ({ title: item.activity_type || 'Activity recorded', detail: item.description || item.activity_status, date: item.activity_date, author: item.created_by_name })), + ...asList(candidate.notes).map((item) => ({ title: 'Internal note added', detail: item.note, date: item.created_at, author: item.created_by_name })), + ...asList(candidate.interviews).map((item) => ({ title: item.interview_type || 'Interview', detail: item.interview_status, date: item.interview_date })), ...(candidate.matched_at ? [{ title: 'Screening completed', detail: candidate.match_summary || candidate.match_status, date: candidate.matched_at }] : []), ...(candidate.applied ? [{ title: 'Application received', detail: candidate.source ? `Applied via ${candidate.source}` : appliedRole(candidate), date: candidate.applied }] : []), ].sort((a, b) => (toDate(b.date)?.getTime() || 0) - (toDate(a.date)?.getTime() || 0)).slice(0, 4) @@ -183,7 +193,7 @@ export default function CandidateWorkspaceOverview({ candidate, stage, nextStage const { can } = useAuth() const { toast } = useToast() const skills = profileSkills(candidate) - const documents = candidate.documents || [] + const documents = asList(candidate.documents) const canMove = can('pipeline.edit') && Boolean(candidate.inbox_id || candidate.manual_upload_candidate_id) const isClosed = ['Rejected', 'Hired'].includes(stage) const status = isClosed ? 'Closed' : stage === 'On Hold' ? 'On hold' : 'In progress' @@ -224,7 +234,7 @@ export default function CandidateWorkspaceOverview({ candidate, stage, nextStage
{candidate.match_summary &&

{candidate.match_summary}

}{candidate.match_reasoning &&

{candidate.match_reasoning}

}{score == null && !candidate.match_summary && !candidate.match_reasoning &&

Compare this candidate’s resume with the assigned role.

}
} - {candidate.job_posts?.length > 0 &&
{candidate.job_posts.map((job) => {job.title})}
} + {asList(candidate.job_posts).length > 0 &&
{asList(candidate.job_posts).map((job) => {job.title || job.id})}
}