This commit is contained in:
Daniel Noulet 2026-09-19 20:56:27 +02:00
parent 6ffc0d2c99
commit 6153017b62
22 changed files with 835 additions and 100 deletions

View file

@ -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 set cannot be changed, and its duration is snapshotted so later job edits do not
alter an active interview. alter an active interview.
Invitation URLs use an opaque UUID token and public `/api/interviews/{token}` routes; Invitation URLs expose an opaque UUID token as `/participant/:invitationId` and use
they do not use interviewer JWT authentication. An invitation progresses from 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 `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 ## 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 at least two options for those types. Open and code-review questions instead
require a `referenceAnswer` textarea. Never include that interviewer-only answer require a `referenceAnswer` textarea. Never include that interviewer-only answer
in public interview responses. 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 - Traits are de-duplicated case-insensitively through their normalized database
value. Labels and both skill groups all reuse the same `traits` table. value. Labels and both skill groups all reuse the same `traits` table.
- Participant creation is multipart form data using `firstName`, `lastName`, - Participant creation is multipart form data using `firstName`, `lastName`,

View file

@ -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 The React application uses a typed API client and routes for authentication, job
postings, the searchable question library, participants, candidate matches, and postings, the searchable question library, participants, candidate matches, and
invitations. Public `/interview/:token` routes provide the participant welcome, invitations. Public `/participant/:invitationId` routes provide an acceptance-gated
timed question navigation, autosaved answers, and completion flow without requiring welcome screen, timed question navigation, autosaved answers, progress, and
an interviewer login. Vite proxies `/api` to the Go service during local development. 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 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 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. Open and code-review questions store an interviewer-only reference answer.
Multiple-choice and radio questions instead store a list of possible responses. Multiple-choice and radio questions instead store a list of possible responses.
Reference answers are deliberately omitted from the public participant interview 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.

View file

@ -38,6 +38,7 @@ type Question struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Text string `gorm:"type:text;not null"` Text string `gorm:"type:text;not null"`
Type string `gorm:"size:32;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:''"` ReferenceAnswer string `gorm:"type:text;not null;default:''"`
Deprecated bool `gorm:"not null;default:false"` Deprecated bool `gorm:"not null;default:false"`
Options []QuestionOption `gorm:"foreignKey:QuestionID;constraint:OnDelete:CASCADE"` Options []QuestionOption `gorm:"foreignKey:QuestionID;constraint:OnDelete:CASCADE"`

View file

@ -46,8 +46,9 @@ func questionsToService(values []dbmodel.Question) []servicemodel.Question {
} }
result[i] = servicemodel.Question{ result[i] = servicemodel.Question{
ID: value.ID, Text: value.Text, Type: value.Type, ID: value.ID, Text: value.Text, Type: value.Type,
Options: options, ReferenceAnswer: value.ReferenceAnswer, Options: options, CodeSnippet: value.CodeSnippet,
Deprecated: value.Deprecated, ReferenceAnswer: value.ReferenceAnswer,
Deprecated: value.Deprecated,
} }
} }
return result return result

View file

@ -147,7 +147,8 @@ func (s *Store) CreateQuestion(ctx context.Context, value servicemodel.Question)
} }
entity := dbmodel.Question{ entity := dbmodel.Question{
Text: value.Text, Type: value.Type, Options: options, 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 { if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil {
return servicemodel.Question{}, mapError(err) 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 { err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&dbmodel.Question{}).Where("id = ?", id). result := tx.Model(&dbmodel.Question{}).Where("id = ?", id).
Updates(map[string]any{ 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 { if result.Error != nil {
return result.Error return result.Error

View file

@ -34,6 +34,7 @@ type Question struct {
Text string Text string
Type string Type string
Options []string Options []string
CodeSnippet string
ReferenceAnswer string ReferenceAnswer string
Deprecated bool Deprecated bool
} }

View file

@ -55,6 +55,7 @@ type IParticipantService interface {
type IInvitationService interface { type IInvitationService interface {
List(context.Context, uint) ([]model.Invitation, error) List(context.Context, uint) ([]model.Invitation, error)
Create(context.Context, uint, 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) GetInterview(context.Context, string) (model.Interview, error)
Accept(context.Context, string) (model.Interview, error) Accept(context.Context, string) (model.Interview, error)
SaveAnswer(context.Context, string, uint, string) 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) { func validateQuestion(value model.Question) (model.Question, error) {
value.Text = strings.TrimSpace(value.Text) value.Text = strings.TrimSpace(value.Text)
value.CodeSnippet = strings.TrimSpace(value.CodeSnippet)
value.ReferenceAnswer = strings.TrimSpace(value.ReferenceAnswer) value.ReferenceAnswer = strings.TrimSpace(value.ReferenceAnswer)
switch value.Type { switch value.Type {
case "open", "multiple_choice", "radio", "code_review": 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 == "" { if (value.Type == "open" || value.Type == "code_review") && value.ReferenceAnswer == "" {
return model.Question{}, fmt.Errorf("%w: a reference answer is required", ErrInvalidQuestion) 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" { if value.Type == "open" || value.Type == "code_review" {
value.Options = nil value.Options = nil
} else { } else {
value.ReferenceAnswer = "" value.ReferenceAnswer = ""
} }
if value.Type != "code_review" {
value.CodeSnippet = ""
}
return value, nil 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) { func (s *InvitationService) GetInterview(ctx context.Context, token string) (model.Interview, error) {
interview, err := s.store.GetInterview(ctx, token) interview, err := s.store.GetInterview(ctx, token)
if err != nil { if err != nil {

View file

@ -6,6 +6,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/danyel/go-guess/backend/internal/database/repository"
"github.com/danyel/go-guess/backend/internal/service/model" "github.com/danyel/go-guess/backend/internal/service/model"
) )
@ -17,8 +18,9 @@ type fakeStore struct {
type invitationStore struct { type invitationStore struct {
*fakeStore *fakeStore
interview model.Interview interview model.Interview
saved bool invitations []model.Invitation
saved bool
} }
func (f *invitationStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) { 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 f.saved = true
return nil 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) { func (f *fakeStore) FindUserByEmail(context.Context, string) (model.User, error) {
return model.User{}, errors.New("unused") return model.User{}, errors.New("unused")
@ -179,4 +184,26 @@ func TestQuestionReferenceAnswerValidation(t *testing.T) {
if question.ReferenceAnswer != "" || question.Options[0] != "One" { if question.ReferenceAnswer != "" || question.Options[0] != "One" {
t.Fatalf("unexpected normalized question: %#v", question) 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)
}
} }

View file

@ -317,6 +317,14 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, webmapper.InvitationToWeb(value, h.frontendURL)) 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) { func (h *Handler) GetInterview(w http.ResponseWriter, r *http.Request) {
value, err := h.invitations.GetInterview(r.Context(), chi.URLParam(r, "token")) value, err := h.invitations.GetInterview(r.Context(), chi.URLParam(r, "token"))
if handleServiceError(w, r, err) { if handleServiceError(w, r, err) {

View file

@ -19,7 +19,7 @@ func JobToService(value webmodel.JobRequest) servicemodel.JobPosting {
func QuestionToService(value webmodel.QuestionRequest) servicemodel.Question { func QuestionToService(value webmodel.QuestionRequest) servicemodel.Question {
return servicemodel.Question{ return servicemodel.Question{
Text: value.Text, Type: value.Type, Options: value.Options, 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 { func QuestionToWeb(value servicemodel.Question) webmodel.QuestionResponse {
return webmodel.QuestionResponse{ return webmodel.QuestionResponse{
ID: value.ID, Text: value.Text, Type: value.Type, Options: value.Options, 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, ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID,
ParticipantName: value.ParticipantName, ParticipantEmail: value.ParticipantEmail, ParticipantName: value.ParticipantName, ParticipantEmail: value.ParticipantEmail,
Token: value.Token, Status: value.Status, 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, 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)) questions := make([]webmodel.InterviewQuestionResponse, len(value.Job.Questions))
for i, question := range value.Job.Questions { for i, question := range value.Job.Questions {
questions[i] = webmodel.InterviewQuestionResponse{ 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{ 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 { func traitsToService(values []string) []servicemodel.Trait {
result := make([]servicemodel.Trait, len(values)) result := make([]servicemodel.Trait, len(values))
for i, value := range values { for i, value := range values {

View file

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

View file

@ -52,6 +52,7 @@ type QuestionRequest struct {
Text string `json:"text"` Text string `json:"text"`
Type string `json:"type"` Type string `json:"type"`
Options []string `json:"options"` Options []string `json:"options"`
CodeSnippet string `json:"codeSnippet"`
ReferenceAnswer string `json:"referenceAnswer"` ReferenceAnswer string `json:"referenceAnswer"`
} }
@ -60,6 +61,7 @@ type QuestionResponse struct {
Text string `json:"text"` Text string `json:"text"`
Type string `json:"type"` Type string `json:"type"`
Options []string `json:"options"` Options []string `json:"options"`
CodeSnippet string `json:"codeSnippet"`
ReferenceAnswer string `json:"referenceAnswer"` ReferenceAnswer string `json:"referenceAnswer"`
Deprecated bool `json:"deprecated"` Deprecated bool `json:"deprecated"`
} }
@ -123,10 +125,11 @@ type InterviewJobResponse struct {
} }
type InterviewQuestionResponse struct { type InterviewQuestionResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
Text string `json:"text"` Text string `json:"text"`
Type string `json:"type"` Type string `json:"type"`
Options []string `json:"options"` Options []string `json:"options"`
CodeSnippet string `json:"codeSnippet"`
} }
type InterviewResponse struct { type InterviewResponse struct {
@ -135,6 +138,12 @@ type InterviewResponse struct {
Answers map[uint]string `json:"answers"` 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 { type AnswerRequest struct {
Answer string `json:"answer"` Answer string `json:"answer"`
} }

View file

@ -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}/candidates", h.Candidates)
protected.Get("/api/jobs/{id}/invitations", h.ListInvitations) protected.Get("/api/jobs/{id}/invitations", h.ListInvitations)
protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation) protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation)
protected.Get("/api/jobs/{id}/invitations/{invitationId}", h.ReviewInvitation)
protected.Get("/api/questions", h.ListQuestions) protected.Get("/api/questions", h.ListQuestions)
protected.Post("/api/questions", h.CreateQuestion) protected.Post("/api/questions", h.CreateQuestion)
protected.Put("/api/questions/{id}", h.UpdateQuestion) protected.Put("/api/questions/{id}", h.UpdateQuestion)

View file

@ -64,6 +64,9 @@ func (invitationStub) List(context.Context, uint) ([]model.Invitation, error) {
func (invitationStub) Create(context.Context, uint, uint) (model.Invitation, error) { func (invitationStub) Create(context.Context, uint, uint) (model.Invitation, error) {
return model.Invitation{}, nil 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) { func (invitationStub) GetInterview(context.Context, string) (model.Interview, error) {
return model.Interview{}, nil return model.Interview{}, nil
} }

View file

@ -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 <CandidateCard candidate={candidate} />\n ));\n}'
WHERE type = 'code_review' AND code_snippet = '';
-- +goose Down
ALTER TABLE questions DROP COLUMN code_snippet;

View file

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

View file

@ -36,8 +36,9 @@ Supported routes:
- `POST|DELETE /api/jobs/:id/questions/:questionId` - `POST|DELETE /api/jobs/:id/questions/:questionId`
- `GET /api/jobs/:id/candidates` - `GET /api/jobs/:id/candidates`
- `GET|POST /api/jobs/:id/invitations` - `GET|POST /api/jobs/:id/invitations`
- `GET /api/jobs/:jobId/invitations/:invitationId` for protected answer review
- `GET|POST /api/questions` (`GET` accepts `?search=`) - `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 - `PATCH /api/questions/:id` to deprecate or restore a question
- `GET|POST /api/participants` - `GET|POST /api/participants`
- `GET /api/participants/:id` - `GET /api/participants/:id`
@ -54,16 +55,22 @@ Participant creation uses multipart fields `firstName`, `lastName`, `birthday`,
## Interview workflow ## Interview workflow
Authenticated interviewers create and edit reusable questions at `/questions`, configure job 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 status and duration, attach active library questions, and generate invitations for each eligible
detail page. Multiple-choice and radio questions require at least two explicitly added options. 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. 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 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 Participants open the generated `/participant/:invitationId` URL without signing in. The opaque
authorizes the public interview API. Answers are saved when navigating between questions or invitation ID authorizes the existing `/api/interviews/:token` API. Code-review questions are shown
finishing, and the countdown is derived from the backend `acceptedAt` timestamp plus the job in a pull-request-style code panel with a review comment field. Answers are saved when navigating
duration so refreshing cannot reset it. 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 ## Container

View file

@ -11,6 +11,7 @@ const question = {
type: 'open' as const, type: 'open' as const,
options: [], options: [],
referenceAnswer: 'Use timeouts, retries, and idempotency.', referenceAnswer: 'Use timeouts, retries, and idempotency.',
codeSnippet: '',
deprecated: false, deprecated: false,
} }
@ -81,7 +82,7 @@ function mockApi() {
participantEmail: 'amelie@example.com', participantEmail: 'amelie@example.com',
token: 'invite-token', token: 'invite-token',
status: 'pending', status: 'pending',
participantUrl: 'https://example.test/interview/invite-token', participantUrl: 'https://example.test/participant/invite-token',
acceptedAt: null, acceptedAt: null,
completedAt: null, completedAt: null,
createdAt: '2026-09-19T10:00:00Z', createdAt: '2026-09-19T10:00:00Z',
@ -95,7 +96,9 @@ function mockApi() {
return response({ return response({
...question, ...question,
text: 'Updated resilience question', text: 'Updated resilience question',
type: 'code_review',
referenceAnswer: 'Prefer bounded retries with jitter.', referenceAnswer: 'Prefer bounded retries with jitter.',
codeSnippet: '+ return retry(request)',
}) })
if (path === '/api/jobs/7/questions/11') return response({}, method === 'DELETE' ? 200 : 201) if (path === '/api/jobs/7/questions/11') return response({}, method === 'DELETE' ? 200 : 201)
throw new Error(`Unexpected request: ${path}`) throw new Error(`Unexpected request: ${path}`)
@ -175,6 +178,7 @@ describe('Go Guess frontend', () => {
expect(screen.queryByRole('button', { name: 'Remove option Retries' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Remove option Retries' })).not.toBeInTheDocument()
await user.selectOptions(screen.getByLabelText('Response type'), 'code_review') await user.selectOptions(screen.getByLabelText('Response type'), 'code_review')
expect(screen.getByLabelText('Expected answer')).toBeInTheDocument() expect(screen.getByLabelText('Expected answer')).toBeInTheDocument()
expect(screen.getByLabelText('Code snippet')).toBeRequired()
}) })
it('surfaces API failures instead of demo content', async () => { it('surfaces API failures instead of demo content', async () => {
@ -218,6 +222,7 @@ describe('Go Guess frontend', () => {
type: 'open', type: 'open',
options: [], options: [],
referenceAnswer: 'Mention ownership and closure.', referenceAnswer: 'Mention ownership and closure.',
codeSnippet: '',
}), }),
}), }),
) )
@ -231,28 +236,52 @@ describe('Go Guess frontend', () => {
it('populates and persists the reference answer while editing', async () => { it('populates and persists the reference answer while editing', async () => {
authenticate() authenticate()
const user = userEvent.setup() 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') 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') const prompt = screen.getByLabelText('Question prompt')
await user.clear(prompt) await user.clear(prompt)
await user.type(prompt, 'Updated resilience question') await user.type(prompt, 'Updated resilience question')
const reference = screen.getByLabelText('Reference answer') const reference = screen.getByLabelText('Expected answer')
expect(reference).toHaveValue(question.referenceAnswer) expect(reference).toHaveValue(codeReviewQuestion.referenceAnswer)
expect(screen.getByLabelText('Code snippet')).toHaveValue(codeReviewQuestion.codeSnippet)
await user.clear(reference) await user.clear(reference)
await user.type(reference, 'Prefer bounded retries with jitter.') await user.type(reference, 'Prefer bounded retries with jitter.')
await user.click(screen.getByRole('button', { name: 'Save changes' })) await user.click(screen.getByRole('button', { name: 'Save changes' }))
expect(fetch).toHaveBeenCalledWith( expect(fetch).toHaveBeenCalledWith(
'/api/questions/11', '/api/questions/21',
expect.objectContaining({ expect.objectContaining({
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
text: 'Updated resilience question', text: 'Updated resilience question',
type: 'open', type: 'code_review',
options: [], options: [],
referenceAnswer: 'Prefer bounded retries with jitter.', 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() authenticate()
const user = userEvent.setup() const user = userEvent.setup()
renderApp('/jobs/7') renderApp('/jobs/7')
await user.click(await screen.findByRole('tab', { name: /invitations/i })) 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: 'Generate invitation' }))
await user.click(screen.getByRole('button', { name: /send invitation/i }))
expect( expect(
await screen.findByText('https://example.test/interview/invite-token'), await screen.findByText('https://example.test/participant/invite-token'),
).toBeInTheDocument() ).toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith( expect(fetch).toHaveBeenCalledWith(
'/api/jobs/7/invitations', '/api/jobs/7/invitations',
@ -315,7 +343,7 @@ describe('Go Guess frontend', () => {
participantEmail: 'amelie@example.com', participantEmail: 'amelie@example.com',
token: 'invite-token', token: 'invite-token',
status: 'pending', status: 'pending',
participantUrl: 'https://example.test/interview/invite-token', participantUrl: 'https://example.test/participant/invite-token',
acceptedAt: null, acceptedAt: null,
completedAt: null, completedAt: null,
createdAt: now.toISOString(), createdAt: now.toISOString(),
@ -355,17 +383,25 @@ describe('Go Guess frontend', () => {
}) })
throw new Error(`Unexpected request: ${path}`) throw new Error(`Unexpected request: ${path}`)
}) })
renderApp('/interview/invite-token') renderApp('/participant/invite-token')
await act(async () => { await act(async () => {
await Promise.resolve() await Promise.resolve()
await Promise.resolve() await Promise.resolve()
}) })
expect(screen.queryByText(question.text)).not.toBeInTheDocument()
expect(screen.queryByRole('textbox', { name: 'Your answer' })).not.toBeInTheDocument() expect(screen.queryByRole('textbox', { name: 'Your answer' })).not.toBeInTheDocument()
await act(async () => { 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.getByText(question.text)).toBeInTheDocument()
expect(screen.queryByText(question.referenceAnswer)).not.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') expect(screen.getByTestId('countdown')).toHaveTextContent('10:00')
await act(async () => { await act(async () => {
await vi.advanceTimersByTimeAsync(1000) await vi.advanceTimersByTimeAsync(1000)
@ -390,6 +426,137 @@ describe('Go Guess frontend', () => {
fireEvent.click(screen.getByRole('button', { name: 'First' })) fireEvent.click(screen.getByRole('button', { name: 'First' }))
}) })
expect(screen.getByText(question.text)).toBeInTheDocument() 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() 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()
})
}) })

View file

@ -759,24 +759,22 @@ function InvitationsView({
invitations: Invitation[] invitations: Invitation[]
onInvited: (invitation: Invitation) => void onInvited: (invitation: Invitation) => void
}) { }) {
const [participantId, setParticipantId] = useState('') const [sendingId, setSendingId] = useState<number>()
const [sending, setSending] = useState(false) const [review, setReview] = useState<Interview>()
const [reviewingId, setReviewingId] = useState<number>()
const [error, setError] = useState('') const [error, setError] = useState('')
const invitedIds = new Set(invitations.map((invitation) => invitation.participantId)) const invitedIds = new Set(invitations.map((invitation) => invitation.participantId))
const eligible = matches.filter((match) => !invitedIds.has(match.participant.id)) const eligible = matches.filter((match) => !invitedIds.has(match.participant.id))
async function send(event: FormEvent) { async function send(participantId: number) {
event.preventDefault() setSendingId(participantId)
if (!participantId) return
setSending(true)
setError('') setError('')
try { try {
onInvited(await api.jobs.invite(job.id, Number(participantId))) onInvited(await api.jobs.invite(job.id, participantId))
setParticipantId('')
} catch (requestError) { } catch (requestError) {
setError(errorMessage(requestError)) setError(errorMessage(requestError))
} finally { } 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 ( return (
<section className="card"> <section className="card">
<div className="section-heading"> <div className="section-heading">
@ -796,26 +806,29 @@ function InvitationsView({
<p>Invite an eligible candidate and share their private participant URL.</p> <p>Invite an eligible candidate and share their private participant URL.</p>
</div> </div>
</div> </div>
<form className="invite-form" onSubmit={send}> <div className="eligible-list">
<FormField label="Eligible candidate" htmlFor="eligible-candidate"> {eligible.map(({ participant }) => (
<select <article className="eligible-row" key={participant.id}>
id="eligible-candidate" <Avatar person={participant} />
value={participantId} <div>
onChange={(event) => setParticipantId(event.target.value)} <strong>
required {participant.firstName} {participant.lastName}
> </strong>
<option value="">Select a candidate</option> <small>{participant.email}</small>
{eligible.map(({ participant }) => ( </div>
<option value={participant.id} key={participant.id}> <button
{participant.firstName} {participant.lastName} — {participant.email} className="button primary"
</option> type="button"
))} disabled={sendingId === participant.id}
</select> onClick={() => void send(participant.id)}
</FormField> >
<button className="button primary" type="submit" disabled={sending || !participantId}> <Send size={16} />{' '}
<Send size={16} /> {sending ? 'Sending…' : 'Send invitation'} {sendingId === participant.id ? 'Generating…' : 'Generate invitation'}
</button> </button>
</form> </article>
))}
{!eligible.length && <p className="muted">All eligible candidates have been invited.</p>}
</div>
{error && <ErrorAlert message={error} />} {error && <ErrorAlert message={error} />}
<div className="invitation-list"> <div className="invitation-list">
{invitations.map((invitation) => ( {invitations.map((invitation) => (
@ -834,12 +847,84 @@ function InvitationsView({
> >
<Copy size={16} /> Copy URL <Copy size={16} /> Copy URL
</button> </button>
{['accepted', 'completed'].includes(invitation.status) && (
<button
className="button secondary"
type="button"
disabled={reviewingId === invitation.id}
onClick={() => void reviewAnswers(invitation.id)}
>
{reviewingId === invitation.id ? 'Loading…' : 'Review answers'}
</button>
)}
</article> </article>
))} ))}
{!invitations.length && ( {!invitations.length && (
<EmptyState title="No invitations" description="Invite a candidate to this interview." /> <EmptyState title="No invitations" description="Invite a candidate to this interview." />
)} )}
</div> </div>
{review && <InvitationAnswerReview interview={review} onClose={() => setReview(undefined)} />}
</section>
)
}
function InvitationAnswerReview({
interview,
onClose,
}: {
interview: Interview
onClose: () => void
}) {
return (
<section className="answer-review" aria-labelledby="answer-review-title">
<div className="section-heading">
<div>
<span className="eyebrow">Participant submission</span>
<h2 id="answer-review-title">{interview.invitation.participantName}</h2>
<p>{interview.invitation.participantEmail}</p>
</div>
<div className="review-heading-actions">
<StatusBadge value={interview.invitation.status} />
<button className="button ghost" type="button" onClick={onClose}>
Close review
</button>
</div>
</div>
<div className="review-question-list">
{interview.job.questions.map((question, index) => {
const answer = interview.answers[String(question.id)]?.trim()
return (
<article className="review-question" key={question.id}>
<span className="question-number">{index + 1}</span>
<div>
<span className="question-type">{question.type.replace('_', ' ')}</span>
<h3>{question.text}</h3>
{question.type === 'code_review' && question.codeSnippet && (
<CodePanel codeSnippet={question.codeSnippet} />
)}
{(question.type === 'multiple_choice' || question.type === 'radio') &&
question.options.length > 0 && (
<div className="review-options">
<strong>Possible options</strong>
<span>{question.options.join(' · ')}</span>
</div>
)}
{(question.type === 'open' || question.type === 'code_review') &&
question.referenceAnswer && (
<div className="reference-answer">
<strong>Reference answer</strong>
<p>{question.referenceAnswer}</p>
</div>
)}
<div className={answer ? 'participant-answer' : 'participant-answer unanswered'}>
<strong>Participant answer</strong>
<p>{answer || 'Unanswered'}</p>
</div>
</div>
</article>
)
})}
</div>
</section> </section>
) )
} }
@ -863,6 +948,11 @@ function QuestionRow({
</div> </div>
<p>{question.text}</p> <p>{question.text}</p>
{question.options.length > 0 && <small>{question.options.join(' · ')}</small>} {question.options.length > 0 && <small>{question.options.join(' · ')}</small>}
{question.type === 'code_review' && question.codeSnippet && (
<pre className="interviewer-code-snippet">
<code>{question.codeSnippet}</code>
</pre>
)}
{question.referenceAnswer && ( {question.referenceAnswer && (
<div className="reference-answer"> <div className="reference-answer">
<strong>Reference answer</strong> <strong>Reference answer</strong>
@ -1066,6 +1156,9 @@ function QuestionForm({
: [], : [],
) )
const [referenceAnswer, setReferenceAnswer] = useState(initialQuestion?.referenceAnswer ?? '') const [referenceAnswer, setReferenceAnswer] = useState(initialQuestion?.referenceAnswer ?? '')
const [codeSnippet, setCodeSnippet] = useState(
initialQuestion?.type === 'code_review' ? initialQuestion.codeSnippet : '',
)
const [optionDraft, setOptionDraft] = useState('') const [optionDraft, setOptionDraft] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@ -1080,6 +1173,7 @@ function QuestionForm({
} else { } else {
setReferenceAnswer('') setReferenceAnswer('')
} }
if (nextType !== 'code_review') setCodeSnippet('')
} }
function addOption() { function addOption() {
@ -1104,6 +1198,7 @@ function QuestionForm({
type, type,
options: choiceType ? options : [], options: choiceType ? options : [],
referenceAnswer: choiceType ? '' : referenceAnswer.trim(), referenceAnswer: choiceType ? '' : referenceAnswer.trim(),
codeSnippet: type === 'code_review' ? codeSnippet : '',
}) })
if (!initialQuestion) { if (!initialQuestion) {
setText('') setText('')
@ -1111,6 +1206,7 @@ function QuestionForm({
setOptions([]) setOptions([])
setOptionDraft('') setOptionDraft('')
setReferenceAnswer('') setReferenceAnswer('')
setCodeSnippet('')
} }
} catch (requestError) { } catch (requestError) {
setError(errorMessage(requestError)) setError(errorMessage(requestError))
@ -1145,6 +1241,17 @@ function QuestionForm({
<option value="code_review">Code review</option> <option value="code_review">Code review</option>
</select> </select>
</FormField> </FormField>
{type === 'code_review' && (
<FormField label="Code snippet" htmlFor="question-form-code-snippet">
<textarea
id="question-form-code-snippet"
rows={12}
value={codeSnippet}
onChange={(event) => setCodeSnippet(event.target.value)}
required
/>
</FormField>
)}
{!choiceType && ( {!choiceType && (
<FormField <FormField
label={type === 'code_review' ? 'Expected answer' : 'Reference answer'} label={type === 'code_review' ? 'Expected answer' : 'Reference answer'}
@ -1727,8 +1834,57 @@ function EmptyState({ title, description }: { title: string; description: string
) )
} }
function CodePanel({ codeSnippet }: { codeSnippet: string }) {
return (
<section className="code-review-panel" aria-label="Code changes">
<header>
<FileText size={17} />
<strong>Code changes</strong>
</header>
<div className="code-lines">
{codeSnippet.split('\n').map((line, index) => {
const added = line.startsWith('+') && !line.startsWith('+++')
return (
<div className={added ? 'code-line added' : 'code-line'} key={index}>
<span className="line-number" aria-hidden="true">
{index + 1}
</span>
<code>{line || ' '}</code>
</div>
)
})}
</div>
</section>
)
}
function CodeReviewAnswer({
codeSnippet,
answer,
onChange,
}: {
codeSnippet: string
answer: string
onChange: (answer: string) => void
}) {
return (
<div className="code-review-answer">
<CodePanel codeSnippet={codeSnippet} />
<FormField label="Review comment" htmlFor="review-comment">
<textarea
id="review-comment"
rows={7}
value={answer}
onChange={(event) => onChange(event.target.value)}
placeholder="Describe issues, suggestions, and approval notes…"
/>
</FormField>
</div>
)
}
function ParticipantInterviewPage() { function ParticipantInterviewPage() {
const token = useParams().token ?? '' const token = useParams().invitationId ?? ''
const [interview, setInterview] = useState<Interview>() const [interview, setInterview] = useState<Interview>()
const [answers, setAnswers] = useState<Record<string, string>>({}) const [answers, setAnswers] = useState<Record<string, string>>({})
const [index, setIndex] = useState(0) const [index, setIndex] = useState(0)
@ -1737,6 +1893,7 @@ function ParticipantInterviewPage() {
const [working, setWorking] = useState(false) const [working, setWorking] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const finishing = useRef(false) const finishing = useRef(false)
const saveQueues = useRef<Record<string, Promise<void>>>({})
useEffect(() => { useEffect(() => {
let active = true let active = true
@ -1788,13 +1945,21 @@ function ParticipantInterviewPage() {
const questions = interview?.job.questions ?? [] const questions = interview?.job.questions ?? []
const currentQuestion = questions[index] const currentQuestion = questions[index]
function updateAnswer(questionId: number, answer: string) {
const key = String(questionId)
setAnswers((current) => ({ ...current, [key]: answer }))
setError('')
const previous = saveQueues.current[key] ?? Promise.resolve()
saveQueues.current[key] = previous
.then(() => api.interviews.answer(token, questionId, answer))
.catch((requestError) => {
setError(errorMessage(requestError))
})
}
async function persistCurrent() { async function persistCurrent() {
if (!currentQuestion) return if (!currentQuestion) return
await api.interviews.answer( await saveQueues.current[String(currentQuestion.id)]
token,
currentQuestion.id,
answers[String(currentQuestion.id)] ?? '',
)
} }
async function move(target: number) { async function move(target: number) {
@ -1868,6 +2033,9 @@ function ParticipantInterviewPage() {
<h1>Welcome, {interview.invitation.participantName}</h1> <h1>Welcome, {interview.invitation.participantName}</h1>
<h2>{interview.job.title}</h2> <h2>{interview.job.title}</h2>
<p>{interview.job.description}</p> <p>{interview.job.description}</p>
<p className="agreement-copy">
By accepting, you agree to begin the timed interview and submit your responses.
</p>
<div className="welcome-facts"> <div className="welcome-facts">
<span> <span>
<Clock3 size={18} /> {interview.job.durationMinutes} minutes <Clock3 size={18} /> {interview.job.durationMinutes} minutes
@ -1878,7 +2046,7 @@ function ParticipantInterviewPage() {
</div> </div>
{error && <ErrorAlert message={error} />} {error && <ErrorAlert message={error} />}
<button className="button primary" type="button" disabled={working} onClick={accept}> <button className="button primary" type="button" disabled={working} onClick={accept}>
{working ? 'Starting…' : 'Accept and start'} {working ? 'Accepting…' : 'Accept'}
<ArrowRight size={18} /> <ArrowRight size={18} />
</button> </button>
</section> </section>
@ -1946,12 +2114,7 @@ function ParticipantInterviewPage() {
type="radio" type="radio"
name={`question-${currentQuestion.id}`} name={`question-${currentQuestion.id}`}
checked={answer === option} checked={answer === option}
onChange={() => onChange={() => updateAnswer(currentQuestion.id, option)}
setAnswers((current) => ({
...current,
[String(currentQuestion.id)]: option,
}))
}
/> />
{option} {option}
</label> </label>
@ -1969,29 +2132,27 @@ function ParticipantInterviewPage() {
const next = event.target.checked const next = event.target.checked
? [...selectedOptions, option] ? [...selectedOptions, option]
: selectedOptions.filter((item) => item !== option) : selectedOptions.filter((item) => item !== option)
setAnswers((current) => ({ updateAnswer(currentQuestion.id, next.join('\n'))
...current,
[String(currentQuestion.id)]: next.join('\n'),
}))
}} }}
/> />
{option} {option}
</label> </label>
))} ))}
</fieldset> </fieldset>
) : currentQuestion.type === 'code_review' ? (
<CodeReviewAnswer
codeSnippet={currentQuestion.codeSnippet}
answer={answer}
onChange={(value) => updateAnswer(currentQuestion.id, value)}
/>
) : ( ) : (
<label> <label>
<span className="sr-only">Your answer</span> <span className="sr-only">Your answer</span>
<textarea <textarea
aria-label="Your answer" aria-label="Your answer"
rows={currentQuestion.type === 'code_review' ? 12 : 7} rows={7}
value={answer} value={answer}
onChange={(event) => onChange={(event) => updateAnswer(currentQuestion.id, event.target.value)}
setAnswers((current) => ({
...current,
[String(currentQuestion.id)]: event.target.value,
}))
}
placeholder="Type your answer…" placeholder="Type your answer…"
/> />
</label> </label>
@ -2037,7 +2198,7 @@ function ParticipantInterviewPage() {
disabled={working} disabled={working}
onClick={() => void finish()} onClick={() => void finish()}
> >
Finish Submit
</button> </button>
</footer> </footer>
</section> </section>
@ -2083,10 +2244,10 @@ export default function App() {
authStorage.clear() authStorage.clear()
setAuthenticated(false) setAuthenticated(false)
} }
if (location.pathname.startsWith('/interview/')) { if (location.pathname.startsWith('/participant/')) {
return ( return (
<Routes> <Routes>
<Route path="/interview/:token" element={<ParticipantInterviewPage />} /> <Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
) )

View file

@ -117,6 +117,8 @@ export const api = {
request<void>(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }), request<void>(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }),
candidates: (jobId: number) => request<CandidateMatch[]>(`/jobs/${jobId}/candidates`), candidates: (jobId: number) => request<CandidateMatch[]>(`/jobs/${jobId}/candidates`),
invitations: (jobId: number) => request<Invitation[]>(`/jobs/${jobId}/invitations`), invitations: (jobId: number) => request<Invitation[]>(`/jobs/${jobId}/invitations`),
invitation: (jobId: number, invitationId: number) =>
request<Interview>(`/jobs/${jobId}/invitations/${invitationId}`),
invite: (jobId: number, participantId: number) => invite: (jobId: number, participantId: number) =>
request<Invitation>(`/jobs/${jobId}/invitations`, { request<Invitation>(`/jobs/${jobId}/invitations`, {
method: 'POST', method: 'POST',

View file

@ -19,6 +19,12 @@
--radius: 16px; --radius: 16px;
--shadow: 0 12px 35px rgba(14, 42, 56, 0.08); --shadow: 0 12px 35px rgba(14, 42, 56, 0.08);
} }
.eligible-row {
grid-template-columns: auto 1fr;
}
.eligible-row .button {
grid-column: 1 / 3;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
@ -853,12 +859,35 @@ td small {
flex: 1; flex: 1;
margin: 0; margin: 0;
} }
.eligible-list {
display: grid;
gap: 0.75rem;
padding: 1.2rem 1.5rem;
border-bottom: 1px solid var(--line);
}
.eligible-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.8rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: 11px;
background: #f8fbfb;
}
.eligible-row > div {
display: flex;
flex-direction: column;
}
.eligible-row small {
color: var(--muted);
}
.invitation-list { .invitation-list {
padding: 0 1.5rem; padding: 0 1.5rem;
} }
.invitation-row { .invitation-row {
display: grid; display: grid;
grid-template-columns: minmax(180px, 1fr) auto minmax(220px, 1.5fr) auto; grid-template-columns: minmax(180px, 1fr) auto minmax(220px, 1.5fr) auto auto;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
padding: 1rem 0; padding: 1rem 0;
@ -877,6 +906,51 @@ td small {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.answer-review {
border-top: 1px solid var(--line);
}
.review-heading-actions {
display: flex;
align-items: center;
gap: 0.65rem;
}
.review-question-list {
padding: 0 1.5rem 1.5rem;
}
.review-question {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 1rem;
padding: 1.4rem 0;
border-bottom: 1px solid var(--line);
}
.review-question h3 {
margin: 0.35rem 0 1rem;
}
.review-options,
.participant-answer {
margin-top: 0.8rem;
padding: 0.75rem;
border-radius: 8px;
background: #f5f8f9;
}
.review-options strong,
.participant-answer strong {
display: block;
margin-bottom: 0.3rem;
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.participant-answer p {
margin: 0;
white-space: pre-wrap;
}
.participant-answer.unanswered {
color: #934141;
background: #fff0f0;
}
.public-interview { .public-interview {
min-height: 100vh; min-height: 100vh;
@ -914,6 +988,11 @@ td small {
color: #526872; color: #526872;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.agreement-copy {
padding: 0.85rem 1rem;
border-radius: 9px;
background: #f5f8f9;
}
.welcome-facts span, .welcome-facts span,
.timer { .timer {
display: inline-flex; display: inline-flex;
@ -991,6 +1070,55 @@ td small {
.participant-options input { .participant-options input {
width: auto; width: auto;
} }
.code-review-answer {
display: grid;
gap: 1.4rem;
}
.code-review-panel {
overflow: hidden;
border: 1px solid #b9c8cf;
border-radius: 10px;
background: #ffffff;
}
.code-review-panel > header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 0.9rem;
border-bottom: 1px solid #c9d5da;
background: #f6f8fa;
}
.code-lines {
overflow-x: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.82rem;
}
.code-line {
display: grid;
grid-template-columns: 52px minmax(max-content, 1fr);
min-height: 24px;
line-height: 24px;
background: #ffffff;
}
.code-line.added {
background: #dafbe1;
}
.line-number {
padding: 0 0.75rem;
border-right: 1px solid #d8dee4;
color: #6e7781;
background: #f6f8fa;
text-align: right;
user-select: none;
}
.code-line.added .line-number {
background: #ccffd8;
}
.code-line code {
padding: 0 0.8rem;
color: #24292f;
white-space: pre;
}
.interview-controls { .interview-controls {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@ -1039,6 +1167,17 @@ td small {
white-space: pre-wrap; white-space: pre-wrap;
margin: 0; margin: 0;
} }
.interviewer-code-snippet {
overflow-x: auto;
margin: 0.75rem 0 0;
padding: 0.8rem;
border: 1px solid var(--line);
border-radius: 8px;
color: #dbe8ed;
background: #102633;
font-size: 0.78rem;
white-space: pre;
}
.ai-label { .ai-label {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -1654,6 +1793,10 @@ fieldset {
flex-direction: column; flex-direction: column;
gap: 0.7rem; gap: 0.7rem;
} }
.review-heading-actions {
align-items: flex-start;
flex-direction: column;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View file

@ -17,6 +17,7 @@ export interface Question {
type: QuestionType type: QuestionType
options: string[] options: string[]
referenceAnswer: string referenceAnswer: string
codeSnippet: string
deprecated: boolean deprecated: boolean
} }
@ -85,7 +86,10 @@ export type CreateJobInput = Omit<
Job, Job,
'id' | 'questions' | 'createdAt' | 'status' | 'durationMinutes' 'id' | 'questions' | 'createdAt' | 'status' | 'durationMinutes'
> >
export type CreateQuestionInput = Pick<Question, 'text' | 'type' | 'options' | 'referenceAnswer'> export type CreateQuestionInput = Pick<
Question,
'text' | 'type' | 'options' | 'referenceAnswer' | 'codeSnippet'
>
export interface CreateParticipantInput { export interface CreateParticipantInput {
firstName: string firstName: string
lastName: string lastName: string