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:
parent
30e3391adb
commit
5dbda53759
12 changed files with 373 additions and 9 deletions
|
|
@ -40,6 +40,7 @@ type IStore interface {
|
||||||
ListParticipants(context.Context) ([]servicemodel.Participant, error)
|
ListParticipants(context.Context) ([]servicemodel.Participant, error)
|
||||||
GetParticipant(context.Context, uint) (servicemodel.Participant, error)
|
GetParticipant(context.Context, uint) (servicemodel.Participant, error)
|
||||||
CreateParticipant(context.Context, servicemodel.Participant) (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)
|
AllTraits(context.Context) ([]servicemodel.Trait, error)
|
||||||
ListInvitations(context.Context, uint) ([]servicemodel.Invitation, error)
|
ListInvitations(context.Context, uint) ([]servicemodel.Invitation, error)
|
||||||
CreateInvitation(context.Context, servicemodel.Invitation) (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)
|
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) {
|
func (s *Store) AllTraits(ctx context.Context) ([]servicemodel.Trait, error) {
|
||||||
var values []dbmodel.Trait
|
var values []dbmodel.Trait
|
||||||
if err := s.db(ctx).Order("name").Find(&values).Error; err != nil {
|
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) {
|
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))
|
result := make([]dbmodel.Trait, 0, len(values))
|
||||||
seen := make(map[string]struct{}, len(values))
|
seen := make(map[string]struct{}, len(values))
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
|
|
@ -634,7 +671,7 @@ func (s *Store) resolveTraits(ctx context.Context, values []servicemodel.Trait)
|
||||||
}
|
}
|
||||||
seen[normalized] = struct{}{}
|
seen[normalized] = struct{}{}
|
||||||
var trait dbmodel.Trait
|
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 {
|
Attrs(dbmodel.Trait{Name: name}).FirstOrCreate(&trait).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,3 +73,37 @@ func TestStorePersistsJobWithTraits(t *testing.T) {
|
||||||
t.Fatalf("unexpected stored job: %#v", found)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ type IParticipantService interface {
|
||||||
List(context.Context) ([]model.Participant, error)
|
List(context.Context) ([]model.Participant, error)
|
||||||
Get(context.Context, uint) (model.Participant, error)
|
Get(context.Context, uint) (model.Participant, error)
|
||||||
Create(context.Context, model.Participant) (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 {
|
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 {
|
if value.DurationMinutes < 1 || value.DurationMinutes > 480 {
|
||||||
return model.JobPosting{}, fmt.Errorf("%w: duration must be between 1 and 480 minutes", ErrValidation)
|
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) {
|
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 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 {
|
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)
|
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 {
|
func extractTraits(content string, traits []model.Trait) []model.Trait {
|
||||||
text := normalize(content)
|
text := normalize(content)
|
||||||
result := make([]model.Trait, 0)
|
result := make([]model.Trait, 0)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ type fakeStore struct {
|
||||||
participants []model.Participant
|
participants []model.Participant
|
||||||
traits []model.Trait
|
traits []model.Trait
|
||||||
createdUser model.User
|
createdUser model.User
|
||||||
|
updatedParticipants []model.Participant
|
||||||
}
|
}
|
||||||
|
|
||||||
type invitationStore struct {
|
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) {
|
func (f *fakeStore) ListParticipants(context.Context) ([]model.Participant, error) {
|
||||||
return f.participants, nil
|
return f.participants, nil
|
||||||
}
|
}
|
||||||
func (f *fakeStore) GetParticipant(context.Context, uint) (model.Participant, error) {
|
func (f *fakeStore) GetParticipant(_ context.Context, id uint) (model.Participant, error) {
|
||||||
return model.Participant{}, nil
|
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) {
|
func (f *fakeStore) CreateParticipant(_ context.Context, value model.Participant) (model.Participant, error) {
|
||||||
return value, nil
|
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) AllTraits(context.Context) ([]model.Trait, error) { return f.traits, nil }
|
||||||
func (f *fakeStore) UpdateJob(_ context.Context, _ uint, _ string, _ int) (model.JobPosting, error) {
|
func (f *fakeStore) UpdateJob(_ context.Context, _ uint, _ string, _ int) (model.JobPosting, error) {
|
||||||
return f.job, nil
|
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) {
|
func TestInvitationRequiresPublishedMatchingJob(t *testing.T) {
|
||||||
store := &invitationStore{fakeStore: &fakeStore{
|
store := &invitationStore{fakeStore: &fakeStore{
|
||||||
job: model.JobPosting{
|
job: model.JobPosting{
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,34 @@ func (h *Handler) CreateParticipant(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusCreated, webmapper.ParticipantToWeb(value))
|
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) {
|
func (h *Handler) ParticipantPhoto(w http.ResponseWriter, r *http.Request) {
|
||||||
value, err := h.participants.Get(r.Context(), idParam(r))
|
value, err := h.participants.Get(r.Context(), idParam(r))
|
||||||
if handleServiceError(w, r, err) {
|
if handleServiceError(w, r, err) {
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
|
||||||
protected.Get("/api/participants", h.ListParticipants)
|
protected.Get("/api/participants", h.ListParticipants)
|
||||||
protected.Post("/api/participants", h.CreateParticipant)
|
protected.Post("/api/participants", h.CreateParticipant)
|
||||||
protected.Get("/api/participants/{id}", h.GetParticipant)
|
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}/photo", h.ParticipantPhoto)
|
||||||
protected.Get("/api/participants/{id}/cv", h.ParticipantCV)
|
protected.Get("/api/participants/{id}/cv", h.ParticipantCV)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,10 @@ func (participantStub) Get(context.Context, uint) (model.Participant, error) {
|
||||||
func (participantStub) Create(context.Context, model.Participant) (model.Participant, error) {
|
func (participantStub) Create(context.Context, model.Participant) (model.Participant, error) {
|
||||||
return model.Participant{}, nil
|
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{}
|
type questionStub struct{}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,53 @@ describe('API client', () => {
|
||||||
expect([...form.keys()]).toEqual(['firstName', 'lastName', 'birthday', 'email', 'contactInfo'])
|
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 () => {
|
it('publishes an entity event after a successful mutation', async () => {
|
||||||
const job = {
|
const job = {
|
||||||
id: 7,
|
id: 7,
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,15 @@ export const api = {
|
||||||
(participant) => ['participants', `participants/${participant.id}`],
|
(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: {
|
users: {
|
||||||
list: () => request<User[]>('/users'),
|
list: () => request<User[]>('/users'),
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ function mockApi() {
|
||||||
})
|
})
|
||||||
if (path === '/api/jobs') return response([job])
|
if (path === '/api/jobs') return response([job])
|
||||||
if (path === '/api/participants') return response([participant])
|
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') return response(job)
|
||||||
if (path === '/api/jobs/7/candidates')
|
if (path === '/api/jobs/7/candidates')
|
||||||
return response([
|
return response([
|
||||||
|
|
@ -320,6 +323,21 @@ describe('Go Guess frontend', () => {
|
||||||
expect(screen.getByLabelText(/upload cv/i)).toHaveAttribute('accept', '.pdf,.doc,.docx')
|
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 () => {
|
it('creates and deprecates questions in the standalone library', async () => {
|
||||||
authenticate()
|
authenticate()
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
|
|
||||||
|
|
@ -164,8 +164,21 @@ export function JobDetailPage() {
|
||||||
|
|
||||||
useDomainEvent<Job | Invitation | ScheduledInterview | undefined>(`jobs/${id}`, (event) => {
|
useDomainEvent<Job | Invitation | ScheduledInterview | undefined>(`jobs/${id}`, (event) => {
|
||||||
const data = event.data
|
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)) {
|
if (isJob(data)) {
|
||||||
setJob(data)
|
setJob(data)
|
||||||
|
if (event.type === 'job.updated') {
|
||||||
|
void api.jobs
|
||||||
|
.candidates(id)
|
||||||
|
.then(setMatches)
|
||||||
|
.catch((value) => setError(errorMessage(value)))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isInvitation(data)) {
|
if (isInvitation(data)) {
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,7 @@ export function ParticipantDetailPage() {
|
||||||
const id = Number(useParams().participantId)
|
const id = Number(useParams().participantId)
|
||||||
const [person, setPerson] = useState<Participant>()
|
const [person, setPerson] = useState<Participant>()
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [uploadingCV, setUploadingCV] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void api.participants
|
void api.participants
|
||||||
.get(id)
|
.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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link className="back-link" to="/participants">
|
<Link className="back-link" to="/participants">
|
||||||
|
|
@ -272,6 +286,15 @@ export function ParticipantDetailPage() {
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue