review
This commit is contained in:
parent
6ffc0d2c99
commit
6153017b62
22 changed files with 835 additions and 100 deletions
14
.github/copilot-instructions.md
vendored
14
.github/copilot-instructions.md
vendored
|
|
@ -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`,
|
||||
|
|
|
|||
11
README.md
11
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.
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ type Question struct {
|
|||
Text string
|
||||
Type string
|
||||
Options []string
|
||||
CodeSnippet string
|
||||
ReferenceAnswer string
|
||||
Deprecated bool
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
21
backend/internal/web/mapper/mapper_test.go
Normal file
21
backend/internal/web/mapper/mapper_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
10
backend/migrations/00005_question_code_snippet.sql
Normal file
10
backend/migrations/00005_question_code_snippet.sql
Normal 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;
|
||||
125
backend/test_review_endpoint.go
Normal file
125
backend/test_review_endpoint.go
Normal 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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<number>()
|
||||
const [review, setReview] = useState<Interview>()
|
||||
const [reviewingId, setReviewingId] = useState<number>()
|
||||
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 (
|
||||
<section className="card">
|
||||
<div className="section-heading">
|
||||
|
|
@ -796,26 +806,29 @@ function InvitationsView({
|
|||
<p>Invite an eligible candidate and share their private participant URL.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form className="invite-form" onSubmit={send}>
|
||||
<FormField label="Eligible candidate" htmlFor="eligible-candidate">
|
||||
<select
|
||||
id="eligible-candidate"
|
||||
value={participantId}
|
||||
onChange={(event) => setParticipantId(event.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select a candidate</option>
|
||||
{eligible.map(({ participant }) => (
|
||||
<option value={participant.id} key={participant.id}>
|
||||
{participant.firstName} {participant.lastName} — {participant.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<button className="button primary" type="submit" disabled={sending || !participantId}>
|
||||
<Send size={16} /> {sending ? 'Sending…' : 'Send invitation'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="eligible-list">
|
||||
{eligible.map(({ participant }) => (
|
||||
<article className="eligible-row" key={participant.id}>
|
||||
<Avatar person={participant} />
|
||||
<div>
|
||||
<strong>
|
||||
{participant.firstName} {participant.lastName}
|
||||
</strong>
|
||||
<small>{participant.email}</small>
|
||||
</div>
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
disabled={sendingId === participant.id}
|
||||
onClick={() => void send(participant.id)}
|
||||
>
|
||||
<Send size={16} />{' '}
|
||||
{sendingId === participant.id ? 'Generating…' : 'Generate invitation'}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
{!eligible.length && <p className="muted">All eligible candidates have been invited.</p>}
|
||||
</div>
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<div className="invitation-list">
|
||||
{invitations.map((invitation) => (
|
||||
|
|
@ -834,12 +847,84 @@ function InvitationsView({
|
|||
>
|
||||
<Copy size={16} /> Copy URL
|
||||
</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>
|
||||
))}
|
||||
{!invitations.length && (
|
||||
<EmptyState title="No invitations" description="Invite a candidate to this interview." />
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -863,6 +948,11 @@ function QuestionRow({
|
|||
</div>
|
||||
<p>{question.text}</p>
|
||||
{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 && (
|
||||
<div className="reference-answer">
|
||||
<strong>Reference answer</strong>
|
||||
|
|
@ -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({
|
|||
<option value="code_review">Code review</option>
|
||||
</select>
|
||||
</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 && (
|
||||
<FormField
|
||||
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() {
|
||||
const token = useParams().token ?? ''
|
||||
const token = useParams().invitationId ?? ''
|
||||
const [interview, setInterview] = useState<Interview>()
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({})
|
||||
const [index, setIndex] = useState(0)
|
||||
|
|
@ -1737,6 +1893,7 @@ function ParticipantInterviewPage() {
|
|||
const [working, setWorking] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const finishing = useRef(false)
|
||||
const saveQueues = useRef<Record<string, Promise<void>>>({})
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
|
@ -1788,13 +1945,21 @@ function ParticipantInterviewPage() {
|
|||
const questions = interview?.job.questions ?? []
|
||||
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() {
|
||||
if (!currentQuestion) return
|
||||
await api.interviews.answer(
|
||||
token,
|
||||
currentQuestion.id,
|
||||
answers[String(currentQuestion.id)] ?? '',
|
||||
)
|
||||
await saveQueues.current[String(currentQuestion.id)]
|
||||
}
|
||||
|
||||
async function move(target: number) {
|
||||
|
|
@ -1868,6 +2033,9 @@ function ParticipantInterviewPage() {
|
|||
<h1>Welcome, {interview.invitation.participantName}</h1>
|
||||
<h2>{interview.job.title}</h2>
|
||||
<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">
|
||||
<span>
|
||||
<Clock3 size={18} /> {interview.job.durationMinutes} minutes
|
||||
|
|
@ -1878,7 +2046,7 @@ function ParticipantInterviewPage() {
|
|||
</div>
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<button className="button primary" type="button" disabled={working} onClick={accept}>
|
||||
{working ? 'Starting…' : 'Accept and start'}
|
||||
{working ? 'Accepting…' : 'Accept'}
|
||||
<ArrowRight size={18} />
|
||||
</button>
|
||||
</section>
|
||||
|
|
@ -1946,12 +2114,7 @@ function ParticipantInterviewPage() {
|
|||
type="radio"
|
||||
name={`question-${currentQuestion.id}`}
|
||||
checked={answer === option}
|
||||
onChange={() =>
|
||||
setAnswers((current) => ({
|
||||
...current,
|
||||
[String(currentQuestion.id)]: option,
|
||||
}))
|
||||
}
|
||||
onChange={() => updateAnswer(currentQuestion.id, option)}
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
|
|
@ -1969,29 +2132,27 @@ function ParticipantInterviewPage() {
|
|||
const next = event.target.checked
|
||||
? [...selectedOptions, option]
|
||||
: selectedOptions.filter((item) => item !== option)
|
||||
setAnswers((current) => ({
|
||||
...current,
|
||||
[String(currentQuestion.id)]: next.join('\n'),
|
||||
}))
|
||||
updateAnswer(currentQuestion.id, next.join('\n'))
|
||||
}}
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : currentQuestion.type === 'code_review' ? (
|
||||
<CodeReviewAnswer
|
||||
codeSnippet={currentQuestion.codeSnippet}
|
||||
answer={answer}
|
||||
onChange={(value) => updateAnswer(currentQuestion.id, value)}
|
||||
/>
|
||||
) : (
|
||||
<label>
|
||||
<span className="sr-only">Your answer</span>
|
||||
<textarea
|
||||
aria-label="Your answer"
|
||||
rows={currentQuestion.type === 'code_review' ? 12 : 7}
|
||||
rows={7}
|
||||
value={answer}
|
||||
onChange={(event) =>
|
||||
setAnswers((current) => ({
|
||||
...current,
|
||||
[String(currentQuestion.id)]: event.target.value,
|
||||
}))
|
||||
}
|
||||
onChange={(event) => updateAnswer(currentQuestion.id, event.target.value)}
|
||||
placeholder="Type your answer…"
|
||||
/>
|
||||
</label>
|
||||
|
|
@ -2037,7 +2198,7 @@ function ParticipantInterviewPage() {
|
|||
disabled={working}
|
||||
onClick={() => void finish()}
|
||||
>
|
||||
Finish
|
||||
Submit
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
|
|
@ -2083,10 +2244,10 @@ export default function App() {
|
|||
authStorage.clear()
|
||||
setAuthenticated(false)
|
||||
}
|
||||
if (location.pathname.startsWith('/interview/')) {
|
||||
if (location.pathname.startsWith('/participant/')) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/interview/:token" element={<ParticipantInterviewPage />} />
|
||||
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -117,6 +117,8 @@ export const api = {
|
|||
request<void>(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }),
|
||||
candidates: (jobId: number) => request<CandidateMatch[]>(`/jobs/${jobId}/candidates`),
|
||||
invitations: (jobId: number) => request<Invitation[]>(`/jobs/${jobId}/invitations`),
|
||||
invitation: (jobId: number, invitationId: number) =>
|
||||
request<Interview>(`/jobs/${jobId}/invitations/${invitationId}`),
|
||||
invite: (jobId: number, participantId: number) =>
|
||||
request<Invitation>(`/jobs/${jobId}/invitations`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@
|
|||
--radius: 16px;
|
||||
--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;
|
||||
|
|
@ -853,12 +859,35 @@ td small {
|
|||
flex: 1;
|
||||
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 {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
.invitation-row {
|
||||
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;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
|
|
@ -877,6 +906,51 @@ td small {
|
|||
text-overflow: ellipsis;
|
||||
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 {
|
||||
min-height: 100vh;
|
||||
|
|
@ -914,6 +988,11 @@ td small {
|
|||
color: #526872;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.agreement-copy {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 9px;
|
||||
background: #f5f8f9;
|
||||
}
|
||||
.welcome-facts span,
|
||||
.timer {
|
||||
display: inline-flex;
|
||||
|
|
@ -991,6 +1070,55 @@ td small {
|
|||
.participant-options input {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
|
@ -1039,6 +1167,17 @@ td small {
|
|||
white-space: pre-wrap;
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -1654,6 +1793,10 @@ fieldset {
|
|||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.review-heading-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export interface Question {
|
|||
type: QuestionType
|
||||
options: string[]
|
||||
referenceAnswer: string
|
||||
codeSnippet: string
|
||||
deprecated: boolean
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +86,10 @@ export type CreateJobInput = Omit<
|
|||
Job,
|
||||
'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 {
|
||||
firstName: string
|
||||
lastName: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue