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

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

View file

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