From 6153017b62e2dfc3dbf31209f5c10d4c8a3dea69 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sat, 19 Sep 2026 20:56:27 +0200 Subject: [PATCH] review --- .github/copilot-instructions.md | 14 +- README.md | 11 +- backend/internal/database/entity/models.go | 1 + backend/internal/database/mapper/mapper.go | 5 +- backend/internal/database/repository/store.go | 6 +- backend/internal/service/model/models.go | 1 + backend/internal/service/services.go | 21 ++ backend/internal/service/services_test.go | 31 +- backend/internal/web/handler/handler.go | 8 + backend/internal/web/mapper/mapper.go | 18 +- backend/internal/web/mapper/mapper_test.go | 21 ++ backend/internal/web/model/models.go | 17 +- backend/internal/web/router/router.go | 1 + backend/internal/web/router/router_test.go | 3 + .../00005_question_code_snippet.sql | 10 + backend/test_review_endpoint.go | 125 ++++++++ frontend/README.md | 23 +- frontend/src/App.test.tsx | 193 ++++++++++++- frontend/src/App.tsx | 273 ++++++++++++++---- frontend/src/api.ts | 2 + frontend/src/styles.css | 145 +++++++++- frontend/src/types.ts | 6 +- 22 files changed, 835 insertions(+), 100 deletions(-) create mode 100644 backend/internal/web/mapper/mapper_test.go create mode 100644 backend/migrations/00005_question_code_snippet.sql create mode 100644 backend/test_review_endpoint.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 064fba8..48609ff 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -62,10 +62,15 @@ only published jobs can create invitations. Once an invitation exists, its quest set cannot be changed, and its duration is snapshotted so later job edits do not alter an active interview. -Invitation URLs use an opaque UUID token and public `/api/interviews/{token}` routes; -they do not use interviewer JWT authentication. An invitation progresses from +Invitation URLs expose an opaque UUID token as `/participant/:invitationId` and use +public `/api/interviews/{token}` API routes; they do not use interviewer JWT +authentication. An invitation progresses from `pending` to `accepted` to `completed`. Answers may only be saved while accepted -and before the snapshotted timer expires. +and before the snapshotted timer expires. The participant UI must gate questions +behind explicit acceptance and provide First, Previous, Next, Last, and Submit +controls. Interviewer review uses the protected +`GET /api/jobs/{id}/invitations/{invitationId}` endpoint, which intentionally +includes reference answers; the public interview endpoint does not. ## Repository conventions @@ -84,6 +89,9 @@ and before the snapshotted timer expires. at least two options for those types. Open and code-review questions instead require a `referenceAnswer` textarea. Never include that interviewer-only answer in public interview responses. +- Code-review questions also require `codeSnippet`. It is safe to include in the + participant payload and is rendered as a line-numbered pull-request review panel; + the participant's review comment remains the ordinary persisted answer. - Traits are de-duplicated case-insensitively through their normalized database value. Labels and both skill groups all reuse the same `traits` table. - Participant creation is multipart form data using `firstName`, `lastName`, diff --git a/README.md b/README.md index 9aafd2f..62958d3 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,11 @@ repository maps service models to GORM entities and persists them in PostgreSQL. The React application uses a typed API client and routes for authentication, job postings, the searchable question library, participants, candidate matches, and -invitations. Public `/interview/:token` routes provide the participant welcome, -timed question navigation, autosaved answers, and completion flow without requiring -an interviewer login. Vite proxies `/api` to the Go service during local development. +invitations. Public `/participant/:invitationId` routes provide an acceptance-gated +welcome screen, timed question navigation, autosaved answers, progress, and +submission without requiring an interviewer login. Interviewers can review every +saved answer from the job's invitation list. Vite proxies `/api` to the Go service +during local development. Goose migrations in `backend/migrations` are the schema source of truth and include representative local data. Uploaded CVs are stored as bytes; plain text, PDF, and @@ -83,4 +85,5 @@ can only be generated for matching participants on published jobs. Open and code-review questions store an interviewer-only reference answer. Multiple-choice and radio questions instead store a list of possible responses. Reference answers are deliberately omitted from the public participant interview -payload. +payload. Code-review questions also store a participant-visible code snippet that +is presented with line numbers in a pull-request-style review panel. diff --git a/backend/internal/database/entity/models.go b/backend/internal/database/entity/models.go index 453b14d..4e51ad5 100644 --- a/backend/internal/database/entity/models.go +++ b/backend/internal/database/entity/models.go @@ -38,6 +38,7 @@ type Question struct { ID uint `gorm:"primaryKey"` Text string `gorm:"type:text;not null"` Type string `gorm:"size:32;not null"` + CodeSnippet string `gorm:"type:text;not null;default:''"` ReferenceAnswer string `gorm:"type:text;not null;default:''"` Deprecated bool `gorm:"not null;default:false"` Options []QuestionOption `gorm:"foreignKey:QuestionID;constraint:OnDelete:CASCADE"` diff --git a/backend/internal/database/mapper/mapper.go b/backend/internal/database/mapper/mapper.go index 44ed104..f63f744 100644 --- a/backend/internal/database/mapper/mapper.go +++ b/backend/internal/database/mapper/mapper.go @@ -46,8 +46,9 @@ func questionsToService(values []dbmodel.Question) []servicemodel.Question { } result[i] = servicemodel.Question{ ID: value.ID, Text: value.Text, Type: value.Type, - Options: options, ReferenceAnswer: value.ReferenceAnswer, - Deprecated: value.Deprecated, + Options: options, CodeSnippet: value.CodeSnippet, + ReferenceAnswer: value.ReferenceAnswer, + Deprecated: value.Deprecated, } } return result diff --git a/backend/internal/database/repository/store.go b/backend/internal/database/repository/store.go index c61b864..3a286fe 100644 --- a/backend/internal/database/repository/store.go +++ b/backend/internal/database/repository/store.go @@ -147,7 +147,8 @@ func (s *Store) CreateQuestion(ctx context.Context, value servicemodel.Question) } entity := dbmodel.Question{ Text: value.Text, Type: value.Type, Options: options, - ReferenceAnswer: value.ReferenceAnswer, Deprecated: value.Deprecated, + CodeSnippet: value.CodeSnippet, ReferenceAnswer: value.ReferenceAnswer, + Deprecated: value.Deprecated, } if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil { return servicemodel.Question{}, mapError(err) @@ -159,7 +160,8 @@ func (s *Store) UpdateQuestion(ctx context.Context, id uint, value servicemodel. err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Model(&dbmodel.Question{}).Where("id = ?", id). Updates(map[string]any{ - "text": value.Text, "type": value.Type, "reference_answer": value.ReferenceAnswer, + "text": value.Text, "type": value.Type, "code_snippet": value.CodeSnippet, + "reference_answer": value.ReferenceAnswer, }) if result.Error != nil { return result.Error diff --git a/backend/internal/service/model/models.go b/backend/internal/service/model/models.go index dee0e7f..30244b2 100644 --- a/backend/internal/service/model/models.go +++ b/backend/internal/service/model/models.go @@ -34,6 +34,7 @@ type Question struct { Text string Type string Options []string + CodeSnippet string ReferenceAnswer string Deprecated bool } diff --git a/backend/internal/service/services.go b/backend/internal/service/services.go index 0b8b155..6789690 100644 --- a/backend/internal/service/services.go +++ b/backend/internal/service/services.go @@ -55,6 +55,7 @@ type IParticipantService interface { type IInvitationService interface { List(context.Context, uint) ([]model.Invitation, error) Create(context.Context, uint, uint) (model.Invitation, error) + Review(context.Context, uint, uint) (model.Interview, error) GetInterview(context.Context, string) (model.Interview, error) Accept(context.Context, string) (model.Interview, error) SaveAnswer(context.Context, string, uint, string) error @@ -181,6 +182,7 @@ func (s *QuestionService) Update(ctx context.Context, id uint, value model.Quest func validateQuestion(value model.Question) (model.Question, error) { value.Text = strings.TrimSpace(value.Text) + value.CodeSnippet = strings.TrimSpace(value.CodeSnippet) value.ReferenceAnswer = strings.TrimSpace(value.ReferenceAnswer) switch value.Type { case "open", "multiple_choice", "radio", "code_review": @@ -203,11 +205,17 @@ func validateQuestion(value model.Question) (model.Question, error) { if (value.Type == "open" || value.Type == "code_review") && value.ReferenceAnswer == "" { return model.Question{}, fmt.Errorf("%w: a reference answer is required", ErrInvalidQuestion) } + if value.Type == "code_review" && value.CodeSnippet == "" { + return model.Question{}, fmt.Errorf("%w: a code snippet is required", ErrInvalidQuestion) + } if value.Type == "open" || value.Type == "code_review" { value.Options = nil } else { value.ReferenceAnswer = "" } + if value.Type != "code_review" { + value.CodeSnippet = "" + } return value, nil } @@ -376,6 +384,19 @@ func (s *InvitationService) Create(ctx context.Context, jobID, participantID uin }) } +func (s *InvitationService) Review(ctx context.Context, jobID, invitationID uint) (model.Interview, error) { + invitations, err := s.store.ListInvitations(ctx, jobID) + if err != nil { + return model.Interview{}, err + } + for _, invitation := range invitations { + if invitation.ID == invitationID { + return s.store.GetInterview(ctx, invitation.Token) + } + } + return model.Interview{}, repository.ErrNotFound +} + func (s *InvitationService) GetInterview(ctx context.Context, token string) (model.Interview, error) { interview, err := s.store.GetInterview(ctx, token) if err != nil { diff --git a/backend/internal/service/services_test.go b/backend/internal/service/services_test.go index 197afae..d25abb0 100644 --- a/backend/internal/service/services_test.go +++ b/backend/internal/service/services_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/danyel/go-guess/backend/internal/database/repository" "github.com/danyel/go-guess/backend/internal/service/model" ) @@ -17,8 +18,9 @@ type fakeStore struct { type invitationStore struct { *fakeStore - interview model.Interview - saved bool + interview model.Interview + invitations []model.Invitation + saved bool } func (f *invitationStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) { @@ -32,6 +34,9 @@ func (f *invitationStore) SaveAnswer(context.Context, string, uint, string) erro f.saved = true return nil } +func (f *invitationStore) ListInvitations(context.Context, uint) ([]model.Invitation, error) { + return f.invitations, nil +} func (f *fakeStore) FindUserByEmail(context.Context, string) (model.User, error) { return model.User{}, errors.New("unused") @@ -179,4 +184,26 @@ func TestQuestionReferenceAnswerValidation(t *testing.T) { if question.ReferenceAnswer != "" || question.Options[0] != "One" { t.Fatalf("unexpected normalized question: %#v", question) } + + if _, err := service.Create(context.Background(), model.Question{ + Text: "Review this change", Type: "code_review", ReferenceAnswer: "Spot the race", + }); !errors.Is(err, ErrInvalidQuestion) { + t.Fatalf("expected missing code snippet error, got %v", err) + } +} + +func TestInvitationReviewIsScopedToJob(t *testing.T) { + store := &invitationStore{ + fakeStore: &fakeStore{}, + invitations: []model.Invitation{{ID: 3, JobID: 8, Token: "opaque-token"}}, + interview: model.Interview{Invitation: model.Invitation{ID: 3, JobID: 8}}, + } + service := NewInvitationService(store, "") + review, err := service.Review(context.Background(), 8, 3) + if err != nil || review.Invitation.ID != 3 { + t.Fatalf("expected invitation review, got %#v, %v", review, err) + } + if _, err := service.Review(context.Background(), 8, 4); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("expected missing invitation error, got %v", err) + } } diff --git a/backend/internal/web/handler/handler.go b/backend/internal/web/handler/handler.go index 13bc2fc..30b0d33 100644 --- a/backend/internal/web/handler/handler.go +++ b/backend/internal/web/handler/handler.go @@ -317,6 +317,14 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, webmapper.InvitationToWeb(value, h.frontendURL)) } +func (h *Handler) ReviewInvitation(w http.ResponseWriter, r *http.Request) { + value, err := h.invitations.Review(r.Context(), idParam(r), secondaryIDParam(r, "invitationId")) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.InvitationReviewToWeb(value, h.frontendURL)) +} + func (h *Handler) GetInterview(w http.ResponseWriter, r *http.Request) { value, err := h.invitations.GetInterview(r.Context(), chi.URLParam(r, "token")) if handleServiceError(w, r, err) { diff --git a/backend/internal/web/mapper/mapper.go b/backend/internal/web/mapper/mapper.go index f871980..803ae27 100644 --- a/backend/internal/web/mapper/mapper.go +++ b/backend/internal/web/mapper/mapper.go @@ -19,7 +19,7 @@ func JobToService(value webmodel.JobRequest) servicemodel.JobPosting { func QuestionToService(value webmodel.QuestionRequest) servicemodel.Question { return servicemodel.Question{ Text: value.Text, Type: value.Type, Options: value.Options, - ReferenceAnswer: value.ReferenceAnswer, + CodeSnippet: value.CodeSnippet, ReferenceAnswer: value.ReferenceAnswer, } } @@ -41,7 +41,8 @@ func JobToWeb(value servicemodel.JobPosting) webmodel.JobResponse { func QuestionToWeb(value servicemodel.Question) webmodel.QuestionResponse { return webmodel.QuestionResponse{ ID: value.ID, Text: value.Text, Type: value.Type, Options: value.Options, - ReferenceAnswer: value.ReferenceAnswer, Deprecated: value.Deprecated, + CodeSnippet: value.CodeSnippet, ReferenceAnswer: value.ReferenceAnswer, + Deprecated: value.Deprecated, } } @@ -76,7 +77,7 @@ func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID, ParticipantName: value.ParticipantName, ParticipantEmail: value.ParticipantEmail, Token: value.Token, Status: value.Status, - ParticipantURL: frontendURL + "/interview/" + value.Token, + ParticipantURL: frontendURL + "/participant/" + value.Token, AcceptedAt: value.AcceptedAt, CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt, } } @@ -85,7 +86,8 @@ func InterviewToWeb(value servicemodel.Interview, frontendURL string) webmodel.I questions := make([]webmodel.InterviewQuestionResponse, len(value.Job.Questions)) for i, question := range value.Job.Questions { questions[i] = webmodel.InterviewQuestionResponse{ - ID: question.ID, Text: question.Text, Type: question.Type, Options: question.Options, + ID: question.ID, Text: question.Text, Type: question.Type, + Options: question.Options, CodeSnippet: question.CodeSnippet, } } return webmodel.InterviewResponse{ @@ -99,6 +101,14 @@ func InterviewToWeb(value servicemodel.Interview, frontendURL string) webmodel.I } } +func InvitationReviewToWeb(value servicemodel.Interview, frontendURL string) webmodel.InvitationReviewResponse { + return webmodel.InvitationReviewResponse{ + Invitation: InvitationToWeb(value.Invitation, frontendURL), + Job: JobToWeb(value.Job), + Answers: value.Answers, + } +} + func traitsToService(values []string) []servicemodel.Trait { result := make([]servicemodel.Trait, len(values)) for i, value := range values { diff --git a/backend/internal/web/mapper/mapper_test.go b/backend/internal/web/mapper/mapper_test.go new file mode 100644 index 0000000..d25d429 --- /dev/null +++ b/backend/internal/web/mapper/mapper_test.go @@ -0,0 +1,21 @@ +package mapper + +import ( + "testing" + + servicemodel "github.com/danyel/go-guess/backend/internal/service/model" +) + +func TestInterviewOmitsReferenceAnswer(t *testing.T) { + response := InterviewToWeb(servicemodel.Interview{ + Job: servicemodel.JobPosting{ + Questions: []servicemodel.Question{{ + ID: 1, Type: "code_review", Text: "Review this", + CodeSnippet: "return nil", ReferenceAnswer: "Should return the error", + }}, + }, + }, "http://localhost") + if len(response.Job.Questions) != 1 || response.Job.Questions[0].CodeSnippet != "return nil" { + t.Fatalf("expected participant-safe code question, got %#v", response) + } +} diff --git a/backend/internal/web/model/models.go b/backend/internal/web/model/models.go index 6abf6a7..f762501 100644 --- a/backend/internal/web/model/models.go +++ b/backend/internal/web/model/models.go @@ -52,6 +52,7 @@ type QuestionRequest struct { Text string `json:"text"` Type string `json:"type"` Options []string `json:"options"` + CodeSnippet string `json:"codeSnippet"` ReferenceAnswer string `json:"referenceAnswer"` } @@ -60,6 +61,7 @@ type QuestionResponse struct { Text string `json:"text"` Type string `json:"type"` Options []string `json:"options"` + CodeSnippet string `json:"codeSnippet"` ReferenceAnswer string `json:"referenceAnswer"` Deprecated bool `json:"deprecated"` } @@ -123,10 +125,11 @@ type InterviewJobResponse struct { } type InterviewQuestionResponse struct { - ID uint `json:"id"` - Text string `json:"text"` - Type string `json:"type"` - Options []string `json:"options"` + ID uint `json:"id"` + Text string `json:"text"` + Type string `json:"type"` + Options []string `json:"options"` + CodeSnippet string `json:"codeSnippet"` } type InterviewResponse struct { @@ -135,6 +138,12 @@ type InterviewResponse struct { Answers map[uint]string `json:"answers"` } +type InvitationReviewResponse struct { + Invitation InvitationResponse `json:"invitation"` + Job JobResponse `json:"job"` + Answers map[uint]string `json:"answers"` +} + type AnswerRequest struct { Answer string `json:"answer"` } diff --git a/backend/internal/web/router/router.go b/backend/internal/web/router/router.go index 96e1bc7..9cac624 100644 --- a/backend/internal/web/router/router.go +++ b/backend/internal/web/router/router.go @@ -38,6 +38,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) protected.Get("/api/jobs/{id}/candidates", h.Candidates) protected.Get("/api/jobs/{id}/invitations", h.ListInvitations) protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation) + protected.Get("/api/jobs/{id}/invitations/{invitationId}", h.ReviewInvitation) protected.Get("/api/questions", h.ListQuestions) protected.Post("/api/questions", h.CreateQuestion) protected.Put("/api/questions/{id}", h.UpdateQuestion) diff --git a/backend/internal/web/router/router_test.go b/backend/internal/web/router/router_test.go index 1a158e0..031fdad 100644 --- a/backend/internal/web/router/router_test.go +++ b/backend/internal/web/router/router_test.go @@ -64,6 +64,9 @@ func (invitationStub) List(context.Context, uint) ([]model.Invitation, error) { func (invitationStub) Create(context.Context, uint, uint) (model.Invitation, error) { return model.Invitation{}, nil } +func (invitationStub) Review(context.Context, uint, uint) (model.Interview, error) { + return model.Interview{}, nil +} func (invitationStub) GetInterview(context.Context, string) (model.Interview, error) { return model.Interview{}, nil } diff --git a/backend/migrations/00005_question_code_snippet.sql b/backend/migrations/00005_question_code_snippet.sql new file mode 100644 index 0000000..e36b12a --- /dev/null +++ b/backend/migrations/00005_question_code_snippet.sql @@ -0,0 +1,10 @@ +-- +goose Up +ALTER TABLE questions + ADD COLUMN code_snippet TEXT NOT NULL DEFAULT ''; + +UPDATE questions +SET code_snippet = E'function CandidateList({ candidates }) {\n return candidates.map(candidate => (\n \n ));\n}' +WHERE type = 'code_review' AND code_snippet = ''; + +-- +goose Down +ALTER TABLE questions DROP COLUMN code_snippet; diff --git a/backend/test_review_endpoint.go b/backend/test_review_endpoint.go new file mode 100644 index 0000000..41724d9 --- /dev/null +++ b/backend/test_review_endpoint.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/danyel/go-guess/backend/internal/security" + "github.com/danyel/go-guess/backend/internal/service/model" + "github.com/danyel/go-guess/backend/internal/web/handler" + "github.com/danyel/go-guess/backend/internal/web/router" +) + +type mockInvitationService struct{} + +func (m mockInvitationService) List(ctx context.Context, jobID uint) ([]model.Invitation, error) { + return []model.Invitation{{ID: 1}}, nil +} + +func (m mockInvitationService) Create(ctx context.Context, jobID, participantID uint) (model.Invitation, error) { + return model.Invitation{}, nil +} + +func (m mockInvitationService) Review(ctx context.Context, jobID, invitationID uint) (model.Interview, error) { + return model.Interview{ + Invitation: model.Invitation{ID: 1, ParticipantName: "Test", ParticipantEmail: "test@example.com", Status: "completed"}, + Job: model.JobPosting{ID: 1, Title: "Job"}, + Answers: map[uint]string{1: "My answer"}, + }, nil +} + +func (m mockInvitationService) GetInterview(ctx context.Context, token string) (model.Interview, error) { + return model.Interview{}, nil +} + +func (m mockInvitationService) Accept(ctx context.Context, token string) (model.Interview, error) { + return model.Interview{}, nil +} + +func (m mockInvitationService) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error { + return nil +} + +func (m mockInvitationService) Finish(ctx context.Context, token string) (model.Interview, error) { + return model.Interview{}, nil +} + +type mockStubs struct{} + +func (mockStubs) Login(context.Context, string, string) (string, model.User, error) { + return "", model.User{}, nil +} + +func (mockStubs) List(context.Context) ([]model.JobPosting, error) { + return []model.JobPosting{}, nil +} +func (mockStubs) Get(context.Context, uint) (model.JobPosting, error) { + return model.JobPosting{}, nil +} +func (mockStubs) Create(context.Context, model.JobPosting) (model.JobPosting, error) { + return model.JobPosting{}, nil +} +func (mockStubs) Update(context.Context, uint, string, int) (model.JobPosting, error) { + return model.JobPosting{}, nil +} +func (mockStubs) AttachQuestion(context.Context, uint, uint) error { return nil } +func (mockStubs) DetachQuestion(context.Context, uint, uint) error { return nil } +func (mockStubs) Candidates(context.Context, uint) ([]model.CandidateMatch, error) { + return []model.CandidateMatch{}, nil +} + +func (mockStubs) List2(context.Context) ([]model.Participant, error) { return nil, nil } +func (mockStubs) Get2(context.Context, uint) (model.Participant, error) { + return model.Participant{}, nil +} +func (mockStubs) Create2(context.Context, model.Participant) (model.Participant, error) { + return model.Participant{}, nil +} + +func (mockStubs) List3(context.Context, string) ([]model.Question, error) { return nil, nil } +func (mockStubs) Create3(context.Context, model.Question) (model.Question, error) { + return model.Question{}, nil +} +func (mockStubs) Update3(context.Context, uint, model.Question) (model.Question, error) { + return model.Question{}, nil +} +func (mockStubs) SetDeprecated(context.Context, uint, bool) (model.Question, error) { + return model.Question{}, nil +} + +func TestReviewEndpoint(t *testing.T) { + // Setup + mockAuth := mockStubs{} + mockJobs := mockStubs{} + mockQuestions := mockStubs{} + mockParticipants := mockStubs{} + mockInvitations := mockInvitationService{} + + h := handler.New(mockAuth, mockJobs, mockQuestions, mockParticipants, mockInvitations, 10, "http://localhost:5173") + tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour) + r := router.New(h, tokens, "http://localhost:5173") + + // Create a valid token + token, err := tokens.Issue(1, "test@example.com", "interviewer") + if err != nil { + t.Fatalf("Failed to create token: %v", err) + } + + // Test the review endpoint + req := httptest.NewRequest(http.MethodGet, "/api/jobs/1/invitations/1", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200, got %d. Response: %s", w.Code, w.Body.String()) + } + + if w.Header().Get("Content-Type") != "application/json" { + t.Errorf("Expected JSON content type, got %s", w.Header().Get("Content-Type")) + } +} diff --git a/frontend/README.md b/frontend/README.md index e742285..bdeeab2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -36,8 +36,9 @@ Supported routes: - `POST|DELETE /api/jobs/:id/questions/:questionId` - `GET /api/jobs/:id/candidates` - `GET|POST /api/jobs/:id/invitations` +- `GET /api/jobs/:jobId/invitations/:invitationId` for protected answer review - `GET|POST /api/questions` (`GET` accepts `?search=`) -- `PUT /api/questions/:id` to update question text, type, options, and reference answer +- `PUT /api/questions/:id` to update question text, type, options, reference answer, and code snippet - `PATCH /api/questions/:id` to deprecate or restore a question - `GET|POST /api/participants` - `GET /api/participants/:id` @@ -54,16 +55,22 @@ Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, ## Interview workflow Authenticated interviewers create and edit reusable questions at `/questions`, configure job -status and duration, attach active library questions, and issue participant invitations from a job -detail page. Multiple-choice and radio questions require at least two explicitly added options. +status and duration, attach active library questions, and generate invitations for each eligible +participant from a job detail page. Multiple-choice and radio questions require at least two +explicitly added options. Detaching a question removes only the job association; questions can be deprecated but not deleted. Open-answer and code-review questions include an interviewer-only reference answer that is never -rendered in the participant interview. +rendered in the participant interview. Code-review questions also require a code snippet. +Accepted and completed invitations can be opened from the job to review every participant answer, +possible choice options, reference answers, and code-review context; unanswered questions are +called out explicitly. -Participants open the generated `/interview/:token` URL without signing in. The invitation token -authorizes the public interview API. Answers are saved when navigating between questions or -finishing, and the countdown is derived from the backend `acceptedAt` timestamp plus the job -duration so refreshing cannot reset it. +Participants open the generated `/participant/:invitationId` URL without signing in. The opaque +invitation ID authorizes the existing `/api/interviews/:token` API. Code-review questions are shown +in a pull-request-style code panel with a review comment field. Answers are saved when navigating +between questions and as they change, then **Submit** completes the interview. The countdown is +derived from the backend `acceptedAt` timestamp plus the job duration so refreshing cannot reset +it. Questions stay hidden until the participant explicitly accepts the invitation. ## Container diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0056860..682bc54 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -11,6 +11,7 @@ const question = { type: 'open' as const, options: [], referenceAnswer: 'Use timeouts, retries, and idempotency.', + codeSnippet: '', deprecated: false, } @@ -81,7 +82,7 @@ function mockApi() { participantEmail: 'amelie@example.com', token: 'invite-token', status: 'pending', - participantUrl: 'https://example.test/interview/invite-token', + participantUrl: 'https://example.test/participant/invite-token', acceptedAt: null, completedAt: null, createdAt: '2026-09-19T10:00:00Z', @@ -95,7 +96,9 @@ function mockApi() { return response({ ...question, text: 'Updated resilience question', + type: 'code_review', referenceAnswer: 'Prefer bounded retries with jitter.', + codeSnippet: '+ return retry(request)', }) if (path === '/api/jobs/7/questions/11') return response({}, method === 'DELETE' ? 200 : 201) throw new Error(`Unexpected request: ${path}`) @@ -175,6 +178,7 @@ describe('Go Guess frontend', () => { expect(screen.queryByRole('button', { name: 'Remove option Retries' })).not.toBeInTheDocument() await user.selectOptions(screen.getByLabelText('Response type'), 'code_review') expect(screen.getByLabelText('Expected answer')).toBeInTheDocument() + expect(screen.getByLabelText('Code snippet')).toBeRequired() }) it('surfaces API failures instead of demo content', async () => { @@ -218,6 +222,7 @@ describe('Go Guess frontend', () => { type: 'open', options: [], referenceAnswer: 'Mention ownership and closure.', + codeSnippet: '', }), }), ) @@ -231,28 +236,52 @@ describe('Go Guess frontend', () => { it('populates and persists the reference answer while editing', async () => { authenticate() const user = userEvent.setup() + const codeReviewQuestion = { + ...question, + id: 21, + type: 'code_review' as const, + text: 'Review this retry change', + codeSnippet: '+ return retry(request)', + } + const defaultFetch = vi.mocked(fetch).getMockImplementation() + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { + const path = String(input) + const method = options?.method ?? 'GET' + if (path.startsWith('/api/questions') && method === 'GET') + return response([codeReviewQuestion]) + if (path === '/api/questions/21' && method === 'PUT') + return response({ + ...codeReviewQuestion, + text: 'Updated resilience question', + referenceAnswer: 'Prefer bounded retries with jitter.', + }) + if (!defaultFetch) throw new Error(`Unexpected request: ${path}`) + return defaultFetch(input, options) + }) renderApp('/questions') - await user.click(await screen.findByRole('button', { name: `Edit ${question.text}` })) + await user.click(await screen.findByRole('button', { name: `Edit ${codeReviewQuestion.text}` })) const prompt = screen.getByLabelText('Question prompt') await user.clear(prompt) await user.type(prompt, 'Updated resilience question') - const reference = screen.getByLabelText('Reference answer') - expect(reference).toHaveValue(question.referenceAnswer) + const reference = screen.getByLabelText('Expected answer') + expect(reference).toHaveValue(codeReviewQuestion.referenceAnswer) + expect(screen.getByLabelText('Code snippet')).toHaveValue(codeReviewQuestion.codeSnippet) await user.clear(reference) await user.type(reference, 'Prefer bounded retries with jitter.') await user.click(screen.getByRole('button', { name: 'Save changes' })) expect(fetch).toHaveBeenCalledWith( - '/api/questions/11', + '/api/questions/21', expect.objectContaining({ method: 'PUT', body: JSON.stringify({ text: 'Updated resilience question', - type: 'open', + type: 'code_review', options: [], referenceAnswer: 'Prefer bounded retries with jitter.', + codeSnippet: '+ return retry(request)', }), }), ) @@ -281,17 +310,16 @@ describe('Go Guess frontend', () => { ) }) - it('sends invitations and displays the generated participant URL', async () => { + it('generates an invitation for an eligible participant and displays its URL', async () => { authenticate() const user = userEvent.setup() renderApp('/jobs/7') await user.click(await screen.findByRole('tab', { name: /invitations/i })) - await user.selectOptions(screen.getByLabelText('Eligible candidate'), '3') - await user.click(screen.getByRole('button', { name: /send invitation/i })) + await user.click(screen.getByRole('button', { name: 'Generate invitation' })) expect( - await screen.findByText('https://example.test/interview/invite-token'), + await screen.findByText('https://example.test/participant/invite-token'), ).toBeInTheDocument() expect(fetch).toHaveBeenCalledWith( '/api/jobs/7/invitations', @@ -315,7 +343,7 @@ describe('Go Guess frontend', () => { participantEmail: 'amelie@example.com', token: 'invite-token', status: 'pending', - participantUrl: 'https://example.test/interview/invite-token', + participantUrl: 'https://example.test/participant/invite-token', acceptedAt: null, completedAt: null, createdAt: now.toISOString(), @@ -355,17 +383,25 @@ describe('Go Guess frontend', () => { }) throw new Error(`Unexpected request: ${path}`) }) - renderApp('/interview/invite-token') + renderApp('/participant/invite-token') await act(async () => { await Promise.resolve() await Promise.resolve() }) + expect(screen.queryByText(question.text)).not.toBeInTheDocument() expect(screen.queryByRole('textbox', { name: 'Your answer' })).not.toBeInTheDocument() await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Accept and start' })) + fireEvent.click(screen.getByRole('button', { name: 'Accept' })) }) expect(screen.getByText(question.text)).toBeInTheDocument() expect(screen.queryByText(question.referenceAnswer)).not.toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'Interview progress' })).toHaveAttribute( + 'aria-valuenow', + '50', + ) + for (const name of ['First', 'Previous', 'Next', 'Last', 'Submit']) { + expect(screen.getByRole('button', { name })).toBeInTheDocument() + } expect(screen.getByTestId('countdown')).toHaveTextContent('10:00') await act(async () => { await vi.advanceTimersByTimeAsync(1000) @@ -390,6 +426,137 @@ describe('Go Guess frontend', () => { fireEvent.click(screen.getByRole('button', { name: 'First' })) }) expect(screen.getByText(question.text)).toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Submit' })) + }) + expect(fetch).toHaveBeenCalledWith( + '/api/interviews/invite-token/finish', + expect.objectContaining({ method: 'POST' }), + ) + expect(screen.getByRole('heading', { name: /thank you/i })).toBeInTheDocument() vi.useRealTimers() }) + + it('renders protected invitation answers for interviewer review', async () => { + authenticate() + const user = userEvent.setup() + const acceptedInvitation = { + id: 44, + jobId: 7, + participantId: 3, + participantName: 'Amélie Dubois', + participantEmail: 'amelie@example.com', + token: 'review-token', + status: 'accepted', + participantUrl: 'https://example.test/participant/review-token', + acceptedAt: '2026-09-19T10:05:00Z', + completedAt: null, + createdAt: '2026-09-19T10:00:00Z', + } + const choiceQuestion = { + ...question, + id: 22, + text: 'Choose a strategy', + type: 'radio' as const, + options: ['Retry', 'Fail fast'], + referenceAnswer: '', + } + const codeQuestion = { + ...question, + id: 23, + text: 'Review the patch', + type: 'code_review' as const, + codeSnippet: '+ return retry(request)', + referenceAnswer: 'Bound the retry count.', + } + const defaultFetch = vi.mocked(fetch).getMockImplementation() + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { + const path = String(input) + if (path === '/api/jobs/7/invitations' && !options?.method) + return response([acceptedInvitation]) + if (path === '/api/jobs/7/invitations/44') + return response({ + invitation: acceptedInvitation, + job: { + id: 7, + title: job.title, + description: job.description, + position: job.position, + seniority: job.seniority, + durationMinutes: job.durationMinutes, + questions: [question, choiceQuestion, codeQuestion], + }, + answers: { + '11': 'Use exponential backoff.', + '23': 'The retry loop needs a maximum.', + }, + }) + if (!defaultFetch) throw new Error(`Unexpected request: ${path}`) + return defaultFetch(input, options) + }) + renderApp('/jobs/7') + + await user.click(await screen.findByRole('tab', { name: /invitations/i })) + await user.click(screen.getByRole('button', { name: 'Review answers' })) + + expect(await screen.findByRole('heading', { name: 'Amélie Dubois' })).toBeInTheDocument() + expect(screen.getByText('Use exponential backoff.')).toBeInTheDocument() + expect(screen.getByText(question.referenceAnswer)).toBeInTheDocument() + expect(screen.getByText('Retry · Fail fast')).toBeInTheDocument() + expect(screen.getByText('Unanswered')).toBeInTheDocument() + expect(screen.getByText('The retry loop needs a maximum.')).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Code changes' })).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith('/api/jobs/7/invitations/44', expect.any(Object)) + }) + + it('renders code review lines and a review comment without exposing the reference answer', async () => { + const now = new Date() + const codeQuestion = { + ...question, + id: 20, + type: 'code_review' as const, + text: 'Review this change', + codeSnippet: ['func total() int {', '+ return 42', '}'].join('\n'), + referenceAnswer: 'The hard-coded value should be replaced.', + } + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL) => { + const path = String(input) + if (path === '/api/interviews/code-token') + return response({ + invitation: { + id: 32, + jobId: 7, + participantId: 3, + participantName: 'Amélie Dubois', + participantEmail: 'amelie@example.com', + token: 'code-token', + status: 'accepted', + participantUrl: 'https://example.test/participant/code-token', + acceptedAt: now.toISOString(), + completedAt: null, + createdAt: now.toISOString(), + }, + job: { + id: 7, + title: job.title, + description: job.description, + position: job.position, + seniority: job.seniority, + durationMinutes: 10, + questions: [codeQuestion], + }, + answers: {}, + }) + throw new Error(`Unexpected request: ${path}`) + }) + + const { container } = renderApp('/participant/code-token') + + expect(await screen.findByRole('region', { name: 'Code changes' })).toBeInTheDocument() + expect(container.querySelectorAll('.code-line')).toHaveLength(3) + expect(container.querySelector('.code-line.added code')?.textContent).toBe('+ return 42') + expect(screen.getByRole('textbox', { name: 'Review comment' })).toBeInTheDocument() + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100') + expect(screen.queryByText(codeQuestion.referenceAnswer)).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1486b9c..0172510 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -759,24 +759,22 @@ function InvitationsView({ invitations: Invitation[] onInvited: (invitation: Invitation) => void }) { - const [participantId, setParticipantId] = useState('') - const [sending, setSending] = useState(false) + const [sendingId, setSendingId] = useState() + const [review, setReview] = useState() + const [reviewingId, setReviewingId] = useState() const [error, setError] = useState('') const invitedIds = new Set(invitations.map((invitation) => invitation.participantId)) const eligible = matches.filter((match) => !invitedIds.has(match.participant.id)) - async function send(event: FormEvent) { - event.preventDefault() - if (!participantId) return - setSending(true) + async function send(participantId: number) { + setSendingId(participantId) setError('') try { - onInvited(await api.jobs.invite(job.id, Number(participantId))) - setParticipantId('') + onInvited(await api.jobs.invite(job.id, participantId)) } catch (requestError) { setError(errorMessage(requestError)) } finally { - setSending(false) + setSendingId(undefined) } } @@ -788,6 +786,18 @@ function InvitationsView({ } } + async function reviewAnswers(invitationId: number) { + setReviewingId(invitationId) + setError('') + try { + setReview(await api.jobs.invitation(job.id, invitationId)) + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setReviewingId(undefined) + } + } + return (
@@ -796,26 +806,29 @@ function InvitationsView({

Invite an eligible candidate and share their private participant URL.

-
- - - - -
+
+ {eligible.map(({ participant }) => ( +
+ +
+ + {participant.firstName} {participant.lastName} + + {participant.email} +
+ +
+ ))} + {!eligible.length &&

All eligible candidates have been invited.

} +
{error && }
{invitations.map((invitation) => ( @@ -834,12 +847,84 @@ function InvitationsView({ > Copy URL + {['accepted', 'completed'].includes(invitation.status) && ( + + )} ))} {!invitations.length && ( )}
+ {review && setReview(undefined)} />} +
+ ) +} + +function InvitationAnswerReview({ + interview, + onClose, +}: { + interview: Interview + onClose: () => void +}) { + return ( +
+
+
+ Participant submission +

{interview.invitation.participantName}

+

{interview.invitation.participantEmail}

+
+
+ + +
+
+
+ {interview.job.questions.map((question, index) => { + const answer = interview.answers[String(question.id)]?.trim() + return ( +
+ {index + 1} +
+ {question.type.replace('_', ' ')} +

{question.text}

+ {question.type === 'code_review' && question.codeSnippet && ( + + )} + {(question.type === 'multiple_choice' || question.type === 'radio') && + question.options.length > 0 && ( +
+ Possible options + {question.options.join(' · ')} +
+ )} + {(question.type === 'open' || question.type === 'code_review') && + question.referenceAnswer && ( +
+ Reference answer +

{question.referenceAnswer}

+
+ )} +
+ Participant answer +

{answer || 'Unanswered'}

+
+
+
+ ) + })} +
) } @@ -863,6 +948,11 @@ function QuestionRow({

{question.text}

{question.options.length > 0 && {question.options.join(' · ')}} + {question.type === 'code_review' && question.codeSnippet && ( +
+            {question.codeSnippet}
+          
+ )} {question.referenceAnswer && (
Reference answer @@ -1066,6 +1156,9 @@ function QuestionForm({ : [], ) const [referenceAnswer, setReferenceAnswer] = useState(initialQuestion?.referenceAnswer ?? '') + const [codeSnippet, setCodeSnippet] = useState( + initialQuestion?.type === 'code_review' ? initialQuestion.codeSnippet : '', + ) const [optionDraft, setOptionDraft] = useState('') const [saving, setSaving] = useState(false) const [error, setError] = useState('') @@ -1080,6 +1173,7 @@ function QuestionForm({ } else { setReferenceAnswer('') } + if (nextType !== 'code_review') setCodeSnippet('') } function addOption() { @@ -1104,6 +1198,7 @@ function QuestionForm({ type, options: choiceType ? options : [], referenceAnswer: choiceType ? '' : referenceAnswer.trim(), + codeSnippet: type === 'code_review' ? codeSnippet : '', }) if (!initialQuestion) { setText('') @@ -1111,6 +1206,7 @@ function QuestionForm({ setOptions([]) setOptionDraft('') setReferenceAnswer('') + setCodeSnippet('') } } catch (requestError) { setError(errorMessage(requestError)) @@ -1145,6 +1241,17 @@ function QuestionForm({ + {type === 'code_review' && ( + +