From 5dbda53759124dfc1db4a3e8fe58be0dc10386f8 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Thu, 1 Oct 2026 17:36:43 +0200 Subject: [PATCH] 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> --- backend/internal/database/repository/store.go | 39 +++++++- .../repository/store_integration_test.go | 34 +++++++ backend/internal/service/services.go | 70 +++++++++++++- backend/internal/service/services_test.go | 96 +++++++++++++++++-- backend/internal/web/handler/handler.go | 28 ++++++ backend/internal/web/router/router.go | 1 + backend/internal/web/router/router_test.go | 4 + frontend/src/api/client.test.ts | 47 +++++++++ frontend/src/api/client.ts | 9 ++ frontend/src/app/App.test.tsx | 18 ++++ frontend/src/features/jobs/pages.tsx | 13 +++ frontend/src/features/participants/pages.tsx | 23 +++++ 12 files changed, 373 insertions(+), 9 deletions(-) diff --git a/backend/internal/database/repository/store.go b/backend/internal/database/repository/store.go index 02104b1..be5da7f 100644 --- a/backend/internal/database/repository/store.go +++ b/backend/internal/database/repository/store.go @@ -40,6 +40,7 @@ type IStore interface { ListParticipants(context.Context) ([]servicemodel.Participant, error) GetParticipant(context.Context, uint) (servicemodel.Participant, error) CreateParticipant(context.Context, servicemodel.Participant) (servicemodel.Participant, error) + UpdateParticipant(context.Context, servicemodel.Participant) (servicemodel.Participant, error) AllTraits(context.Context) ([]servicemodel.Trait, error) ListInvitations(context.Context, uint) ([]servicemodel.Invitation, error) CreateInvitation(context.Context, servicemodel.Invitation) (servicemodel.Invitation, error) @@ -328,6 +329,38 @@ func (s *Store) CreateParticipant(ctx context.Context, value servicemodel.Partic return s.GetParticipant(ctx, entity.ID) } +func (s *Store) UpdateParticipant(ctx context.Context, value servicemodel.Participant) (servicemodel.Participant, error) { + entity := dbmodel.Participant{ + ID: value.ID, + FirstName: value.FirstName, LastName: value.LastName, Birthday: value.Birthday, + Email: value.Email, ContactInfo: value.ContactInfo, Photo: value.Photo, + PhotoType: value.PhotoType, CV: value.CV, CVFilename: value.CVFilename, + } + db := s.db(ctx) + if err := db.Transaction(func(tx *gorm.DB) error { + traits, err := resolveTraits(tx, value.Traits) + if err != nil { + return err + } + entity.Traits = traits + result := tx.Model(&dbmodel.Participant{}).Where("id = ?", value.ID).Updates(map[string]any{ + "first_name": value.FirstName, "last_name": value.LastName, "birthday": value.Birthday, + "email": value.Email, "contact_info": value.ContactInfo, "photo": value.Photo, + "photo_type": value.PhotoType, "cv": value.CV, "cv_filename": value.CVFilename, + }) + if result.Error != nil { + return mapError(result.Error) + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return tx.Model(&entity).Association("Traits").Replace(entity.Traits) + }); err != nil { + return servicemodel.Participant{}, err + } + return s.GetParticipant(ctx, entity.ID) +} + func (s *Store) AllTraits(ctx context.Context) ([]servicemodel.Trait, error) { var values []dbmodel.Trait if err := s.db(ctx).Order("name").Find(&values).Error; err != nil { @@ -621,6 +654,10 @@ func (s *Store) ListCalendar(ctx context.Context, userID uint) ([]servicemodel.S } func (s *Store) resolveTraits(ctx context.Context, values []servicemodel.Trait) ([]dbmodel.Trait, error) { + return resolveTraits(s.db(ctx), values) +} + +func resolveTraits(db *gorm.DB, values []servicemodel.Trait) ([]dbmodel.Trait, error) { result := make([]dbmodel.Trait, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, value := range values { @@ -634,7 +671,7 @@ func (s *Store) resolveTraits(ctx context.Context, values []servicemodel.Trait) } seen[normalized] = struct{}{} var trait dbmodel.Trait - if err := s.db(ctx).Where(dbmodel.Trait{Normalized: normalized}). + if err := db.Where(dbmodel.Trait{Normalized: normalized}). Attrs(dbmodel.Trait{Name: name}).FirstOrCreate(&trait).Error; err != nil { return nil, err } diff --git a/backend/internal/database/repository/store_integration_test.go b/backend/internal/database/repository/store_integration_test.go index 233f4a3..88d1955 100644 --- a/backend/internal/database/repository/store_integration_test.go +++ b/backend/internal/database/repository/store_integration_test.go @@ -73,3 +73,37 @@ func TestStorePersistsJobWithTraits(t *testing.T) { t.Fatalf("unexpected stored job: %#v", found) } } + +func TestStoreUpdatesParticipantAndReplacesTraits(t *testing.T) { + databaseURL := os.Getenv("TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("TEST_DATABASE_URL is not set") + } + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + store := New(db) + suffix := uuid.NewString() + created, err := store.CreateParticipant(context.Background(), model.Participant{ + FirstName: "Ada", LastName: "Lovelace", Email: "candidate-" + suffix + "@example.com", + CV: []byte("Go developer"), CVFilename: "old.txt", + Traits: []model.Trait{{Name: "Go"}}, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Exec("DELETE FROM participants WHERE id = ?", created.ID) }) + + created.CV = []byte("React developer") + created.CVFilename = "new.txt" + created.Traits = []model.Trait{{Name: "React"}} + updated, err := store.UpdateParticipant(context.Background(), created) + if err != nil { + t.Fatal(err) + } + if updated.ID != created.ID || updated.CVFilename != "new.txt" || + len(updated.Traits) != 1 || updated.Traits[0].Name != "React" { + t.Fatalf("unexpected updated participant: %#v", updated) + } +} diff --git a/backend/internal/service/services.go b/backend/internal/service/services.go index 0fb2025..a53e520 100644 --- a/backend/internal/service/services.go +++ b/backend/internal/service/services.go @@ -51,6 +51,8 @@ type IParticipantService interface { List(context.Context) ([]model.Participant, error) Get(context.Context, uint) (model.Participant, error) Create(context.Context, model.Participant) (model.Participant, error) + Update(context.Context, model.Participant) (model.Participant, error) + UpdateTraits(context.Context, uint) error } type IInvitationService interface { @@ -117,7 +119,14 @@ func (s *JobService) Create(ctx context.Context, value model.JobPosting) (model. if value.DurationMinutes < 1 || value.DurationMinutes > 480 { return model.JobPosting{}, fmt.Errorf("%w: duration must be between 1 and 480 minutes", ErrValidation) } - return s.store.CreateJob(ctx, value) + created, err := s.store.CreateJob(ctx, value) + if err != nil { + return model.JobPosting{}, err + } + if err := refreshParticipantTraits(ctx, s.store); err != nil { + return model.JobPosting{}, fmt.Errorf("refresh participant traits: %w", err) + } + return created, nil } func (s *JobService) Update(ctx context.Context, id uint, status string, durationMinutes int) (model.JobPosting, error) { @@ -136,7 +145,14 @@ func (s *JobService) Update(ctx context.Context, id uint, status string, duratio return model.JobPosting{}, fmt.Errorf("%w: a published job requires at least one question", ErrInvalidState) } } - return s.store.UpdateJob(ctx, id, status, durationMinutes) + updated, err := s.store.UpdateJob(ctx, id, status, durationMinutes) + if err != nil { + return model.JobPosting{}, err + } + if err := refreshParticipantTraits(ctx, s.store); err != nil { + return model.JobPosting{}, fmt.Errorf("refresh participant traits: %w", err) + } + return updated, nil } func (s *JobService) AttachQuestion(ctx context.Context, jobID, questionID uint) error { @@ -307,6 +323,56 @@ func (s *ParticipantService) Create(ctx context.Context, value model.Participant return s.store.CreateParticipant(ctx, value) } +func (s *ParticipantService) Update(ctx context.Context, value model.Participant) (model.Participant, error) { + value.FirstName = strings.TrimSpace(value.FirstName) + value.LastName = strings.TrimSpace(value.LastName) + value.Email = strings.ToLower(strings.TrimSpace(value.Email)) + if value.FirstName == "" || value.LastName == "" || value.Email == "" { + return model.Participant{}, fmt.Errorf("%w: first name, last name, and email are required", ErrValidation) + } + allTraits, err := s.store.AllTraits(ctx) + if err != nil { + return model.Participant{}, err + } + text, err := extractCVText(value.CVFilename, value.CV) + if err != nil { + return model.Participant{}, fmt.Errorf("%w: extract CV text: %v", ErrValidation, err) + } + value.Traits = extractTraits(text, allTraits) + return s.store.UpdateParticipant(ctx, value) +} + +func (s *ParticipantService) UpdateTraits(ctx context.Context, id uint) error { + participant, err := s.store.GetParticipant(ctx, id) + if err != nil { + return err + } + _, err = s.Update(ctx, participant) + return err +} + +func refreshParticipantTraits(ctx context.Context, store repository.IStore) error { + participants, err := store.ListParticipants(ctx) + if err != nil { + return err + } + traits, err := store.AllTraits(ctx) + if err != nil { + return err + } + for _, participant := range participants { + text, err := extractCVText(participant.CVFilename, participant.CV) + if err != nil { + return fmt.Errorf("extract participant %d CV text: %w", participant.ID, err) + } + participant.Traits = extractTraits(text, traits) + if _, err := store.UpdateParticipant(ctx, participant); err != nil { + return fmt.Errorf("update participant %d traits: %w", participant.ID, err) + } + } + return nil +} + func extractTraits(content string, traits []model.Trait) []model.Trait { text := normalize(content) result := make([]model.Trait, 0) diff --git a/backend/internal/service/services_test.go b/backend/internal/service/services_test.go index c433389..9a0a580 100644 --- a/backend/internal/service/services_test.go +++ b/backend/internal/service/services_test.go @@ -13,10 +13,11 @@ import ( ) type fakeStore struct { - job model.JobPosting - participants []model.Participant - traits []model.Trait - createdUser model.User + job model.JobPosting + participants []model.Participant + traits []model.Trait + createdUser model.User + updatedParticipants []model.Participant } type invitationStore struct { @@ -130,12 +131,26 @@ func (f *fakeStore) UpdateQuestion(_ context.Context, _ uint, value model.Questi func (f *fakeStore) ListParticipants(context.Context) ([]model.Participant, error) { return f.participants, nil } -func (f *fakeStore) GetParticipant(context.Context, uint) (model.Participant, error) { - return model.Participant{}, nil +func (f *fakeStore) GetParticipant(_ context.Context, id uint) (model.Participant, error) { + for _, participant := range f.participants { + if participant.ID == id { + return participant, nil + } + } + return model.Participant{}, repository.ErrNotFound } func (f *fakeStore) CreateParticipant(_ context.Context, value model.Participant) (model.Participant, error) { return value, nil } +func (f *fakeStore) UpdateParticipant(_ context.Context, value model.Participant) (model.Participant, error) { + f.updatedParticipants = append(f.updatedParticipants, value) + for index := range f.participants { + if f.participants[index].ID == value.ID { + f.participants[index] = value + } + } + return value, nil +} func (f *fakeStore) AllTraits(context.Context) ([]model.Trait, error) { return f.traits, nil } func (f *fakeStore) UpdateJob(_ context.Context, _ uint, _ string, _ int) (model.JobPosting, error) { return f.job, nil @@ -252,6 +267,75 @@ func TestParticipantCreateExtractsKnownTraits(t *testing.T) { } } +func TestCreatingJobRefreshesExistingParticipantTraits(t *testing.T) { + store := &fakeStore{ + traits: []model.Trait{{Name: "Go"}, {Name: "Kubernetes"}}, + participants: []model.Participant{{ + ID: 7, FirstName: "Ada", LastName: "Lovelace", Email: "ada@example.com", + CV: []byte("Platform engineer experienced with Kubernetes."), CVFilename: "cv.txt", + Traits: []model.Trait{{Name: "Go"}}, + }}, + } + + _, err := NewJobService(store).Create(context.Background(), model.JobPosting{ + Title: "Platform Engineer", Description: "Operate the platform", + Position: "Engineer", Seniority: "Senior", + RequiredSkills: []model.Trait{{Name: "Kubernetes"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(store.updatedParticipants) != 1 || + len(store.updatedParticipants[0].Traits) != 1 || + store.updatedParticipants[0].Traits[0].Name != "Kubernetes" { + t.Fatalf("participant traits were not refreshed: %#v", store.updatedParticipants) + } +} + +func TestUpdatingJobRefreshesExistingParticipantTraits(t *testing.T) { + store := &fakeStore{ + job: model.JobPosting{ID: 3, Status: "draft", DurationMinutes: 60}, + traits: []model.Trait{{Name: "Go"}, {Name: "Kubernetes"}}, + participants: []model.Participant{{ + ID: 7, FirstName: "Ada", LastName: "Lovelace", Email: "ada@example.com", + CV: []byte("Platform engineer experienced with Kubernetes."), CVFilename: "cv.txt", + Traits: []model.Trait{{Name: "Go"}}, + }}, + } + + if _, err := NewJobService(store).Update(context.Background(), 3, "draft", 60); err != nil { + t.Fatal(err) + } + if len(store.updatedParticipants) != 1 || + len(store.updatedParticipants[0].Traits) != 1 || + store.updatedParticipants[0].Traits[0].Name != "Kubernetes" { + t.Fatalf("participant traits were not refreshed: %#v", store.updatedParticipants) + } +} + +func TestParticipantUpdateReplacesCVTraits(t *testing.T) { + store := &fakeStore{ + traits: []model.Trait{{Name: "Go"}, {Name: "React"}}, + participants: []model.Participant{{ + ID: 7, FirstName: "Ada", LastName: "Lovelace", Email: "ada@example.com", + CV: []byte("Go developer"), CVFilename: "old.txt", + Traits: []model.Trait{{Name: "Go"}}, + }}, + } + participant := store.participants[0] + participant.CV = []byte("React developer") + participant.CVFilename = "new.txt" + + updated, err := NewParticipantService(store).Update(context.Background(), participant) + if err != nil { + t.Fatal(err) + } + if len(store.updatedParticipants) != 1 || len(updated.Traits) != 1 || + updated.Traits[0].Name != "React" { + t.Fatalf("CV traits were not replaced: %#v", updated) + } +} + func TestInvitationRequiresPublishedMatchingJob(t *testing.T) { store := &invitationStore{fakeStore: &fakeStore{ job: model.JobPosting{ diff --git a/backend/internal/web/handler/handler.go b/backend/internal/web/handler/handler.go index e536767..6018601 100644 --- a/backend/internal/web/handler/handler.go +++ b/backend/internal/web/handler/handler.go @@ -297,6 +297,34 @@ func (h *Handler) CreateParticipant(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, webmapper.ParticipantToWeb(value)) } +func (h *Handler) UpdateParticipantCV(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, h.maxUpload) + if err := r.ParseMultipartForm(h.maxUpload); err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("invalid multipart form or upload too large")) + return + } + cv, _, filename, err := readUpload(r, "cv") + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if len(cv) == 0 { + writeError(w, http.StatusBadRequest, fmt.Errorf("cv is required")) + return + } + participant, err := h.participants.Get(r.Context(), idParam(r)) + if handleServiceError(w, r, err) { + return + } + participant.CV = cv + participant.CVFilename = filename + value, err := h.participants.Update(r.Context(), participant) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.ParticipantToWeb(value)) +} + func (h *Handler) ParticipantPhoto(w http.ResponseWriter, r *http.Request) { value, err := h.participants.Get(r.Context(), idParam(r)) if handleServiceError(w, r, err) { diff --git a/backend/internal/web/router/router.go b/backend/internal/web/router/router.go index 969a2e6..ac2de26 100644 --- a/backend/internal/web/router/router.go +++ b/backend/internal/web/router/router.go @@ -60,6 +60,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) protected.Get("/api/participants", h.ListParticipants) protected.Post("/api/participants", h.CreateParticipant) protected.Get("/api/participants/{id}", h.GetParticipant) + protected.Put("/api/participants/{id}/cv", h.UpdateParticipantCV) protected.Get("/api/participants/{id}/photo", h.ParticipantPhoto) protected.Get("/api/participants/{id}/cv", h.ParticipantCV) }) diff --git a/backend/internal/web/router/router_test.go b/backend/internal/web/router/router_test.go index 23cf473..9d216e1 100644 --- a/backend/internal/web/router/router_test.go +++ b/backend/internal/web/router/router_test.go @@ -47,6 +47,10 @@ func (participantStub) Get(context.Context, uint) (model.Participant, error) { func (participantStub) Create(context.Context, model.Participant) (model.Participant, error) { return model.Participant{}, nil } +func (participantStub) Update(context.Context, model.Participant) (model.Participant, error) { + return model.Participant{}, nil +} +func (participantStub) UpdateTraits(context.Context, uint) error { return nil } type questionStub struct{} diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 6c76d72..0247f7b 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -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, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 93d8af7..dda3da7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(`/participants/${id}/cv`, { method: 'PUT', body: form }), + 'participant.cv-updated', + (participant) => ['participants', `participants/${participant.id}`, 'jobs'], + ) + }, }, users: { list: () => request('/users'), diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx index a6498e6..347e68d 100644 --- a/frontend/src/app/App.test.tsx +++ b/frontend/src/app/App.test.tsx @@ -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() diff --git a/frontend/src/features/jobs/pages.tsx b/frontend/src/features/jobs/pages.tsx index 7b95f30..77619cc 100644 --- a/frontend/src/features/jobs/pages.tsx +++ b/frontend/src/features/jobs/pages.tsx @@ -164,8 +164,21 @@ export function JobDetailPage() { useDomainEvent(`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)) { diff --git a/frontend/src/features/participants/pages.tsx b/frontend/src/features/participants/pages.tsx index a381641..e9f79c4 100644 --- a/frontend/src/features/participants/pages.tsx +++ b/frontend/src/features/participants/pages.tsx @@ -205,6 +205,7 @@ export function ParticipantDetailPage() { const id = Number(useParams().participantId) const [person, setPerson] = useState() 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 ( <> @@ -272,6 +286,15 @@ export function ParticipantDetailPage() { )} +