feat: refresh candidate traits after CV changes

Recalculate stored participant traits when job postings change and expose replacement CV uploads with live candidate refresh events.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-10-01 17:36:43 +02:00
parent 30e3391adb
commit 5dbda53759
12 changed files with 373 additions and 9 deletions

View file

@ -84,6 +84,53 @@ describe('API client', () => {
expect([...form.keys()]).toEqual(['firstName', 'lastName', 'birthday', 'email', 'contactInfo'])
})
it('uploads a replacement CV and publishes participant and job events', async () => {
authStorage.save({
token: 'signed-jwt',
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
})
const participant = {
id: 4,
firstName: 'A',
lastName: 'B',
email: 'a@example.com',
contactInfo: 'Brussels',
traits: ['React'],
cvFilename: 'replacement.pdf',
createdAt: '2026-09-01T00:00:00Z',
}
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(participant), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)
const participantListener = vi.fn()
const jobsListener = vi.fn()
const unsubscribeParticipant = subscribeToDomainEvent('participants/4', participantListener)
const unsubscribeJobs = subscribeToDomainEvent('jobs', jobsListener)
const cv = new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' })
await api.participants.updateCV(4, cv)
const options = fetchMock.mock.calls[0][1] as RequestInit
const form = options.body as FormData
expect(fetchMock).toHaveBeenCalledWith(
'/api/participants/4/cv',
expect.objectContaining({ method: 'PUT' }),
)
expect(form.get('cv')).toBe(cv)
expect(participantListener).toHaveBeenCalledWith(
expect.objectContaining({ type: 'participant.cv-updated', data: participant }),
)
expect(jobsListener).toHaveBeenCalledWith(
expect.objectContaining({ type: 'participant.cv-updated', data: participant }),
)
unsubscribeParticipant()
unsubscribeJobs()
})
it('publishes an entity event after a successful mutation', async () => {
const job = {
id: 7,

View file

@ -369,6 +369,15 @@ export const api = {
(participant) => ['participants', `participants/${participant.id}`],
)
},
updateCV: (id: number, cv: File) => {
const form = new FormData()
form.append('cv', cv)
return mutation(
request<Participant>(`/participants/${id}/cv`, { method: 'PUT', body: form }),
'participant.cv-updated',
(participant) => ['participants', `participants/${participant.id}`, 'jobs'],
)
},
},
users: {
list: () => request<User[]>('/users'),

View file

@ -62,6 +62,9 @@ function mockApi() {
})
if (path === '/api/jobs') return response([job])
if (path === '/api/participants') return response([participant])
if (path === '/api/participants/3' && method === 'GET') return response(participant)
if (path === '/api/participants/3/cv' && method === 'PUT')
return response({ ...participant, cvFilename: 'replacement.pdf', traits: ['React'] })
if (path === '/api/jobs/7') return response(job)
if (path === '/api/jobs/7/candidates')
return response([
@ -320,6 +323,21 @@ describe('Go Guess frontend', () => {
expect(screen.getByLabelText(/upload cv/i)).toHaveAttribute('accept', '.pdf,.doc,.docx')
})
it('replaces a participant CV and displays recalculated traits', async () => {
authenticate()
const user = userEvent.setup()
renderApp('/participants/3')
const input = await screen.findByLabelText(/replace cv/i)
await user.upload(input, new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' }))
expect(await screen.findByText('React')).toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith(
'/api/participants/3/cv',
expect.objectContaining({ method: 'PUT' }),
)
})
it('creates and deprecates questions in the standalone library', async () => {
authenticate()
const user = userEvent.setup()

View file

@ -164,8 +164,21 @@ export function JobDetailPage() {
useDomainEvent<Job | Invitation | ScheduledInterview | undefined>(`jobs/${id}`, (event) => {
const data = event.data
if (event.type === 'participant.cv-updated') {
void api.jobs
.candidates(id)
.then(setMatches)
.catch((value) => setError(errorMessage(value)))
return
}
if (isJob(data)) {
setJob(data)
if (event.type === 'job.updated') {
void api.jobs
.candidates(id)
.then(setMatches)
.catch((value) => setError(errorMessage(value)))
}
return
}
if (isInvitation(data)) {

View file

@ -205,6 +205,7 @@ export function ParticipantDetailPage() {
const id = Number(useParams().participantId)
const [person, setPerson] = useState<Participant>()
const [error, setError] = useState('')
const [uploadingCV, setUploadingCV] = useState(false)
useEffect(() => {
void api.participants
.get(id)
@ -229,6 +230,19 @@ export function ParticipantDetailPage() {
}
}
async function replaceCV(file?: File) {
if (!file) return
setUploadingCV(true)
setError('')
try {
setPerson(await api.participants.updateCV(id, file))
} catch (requestError) {
setError(errorMessage(requestError))
} finally {
setUploadingCV(false)
}
}
return (
<>
<Link className="back-link" to="/participants">
@ -272,6 +286,15 @@ export function ParticipantDetailPage() {
</span>
</button>
)}
<label className="button secondary file-button">
<Upload size={17} /> {uploadingCV ? 'Updating CV…' : 'Replace CV'}
<input
type="file"
accept=".pdf,.doc,.docx"
disabled={uploadingCV}
onChange={(event) => void replaceCV(event.target.files?.[0])}
/>
</label>
</aside>
</div>
</>