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
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`,

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
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.

View file

@ -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"`

View file

@ -46,7 +46,8 @@ 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,
Options: options, CodeSnippet: value.CodeSnippet,
ReferenceAnswer: value.ReferenceAnswer,
Deprecated: value.Deprecated,
}
}

View file

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

View file

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

View file

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

View file

@ -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"
)
@ -18,6 +19,7 @@ type fakeStore struct {
type invitationStore struct {
*fakeStore
interview model.Interview
invitations []model.Invitation
saved bool
}
@ -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)
}
}

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

View file

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

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"`
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"`
}
@ -127,6 +129,7 @@ type InterviewQuestionResponse struct {
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"`
}

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

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

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`
- `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

View file

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

View file

@ -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>
<div className="eligible-list">
{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'}
<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>
</form>
</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>
)

View file

@ -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',

View file

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

View file

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