feat: add collaborative interview scheduling

Add candidate decisions, co-interviewer accounts, scheduled interview inbox and calendar views, candidate-safe meeting links, shared documents, and private live notes backed by RabbitMQ.

Extend the disposable integration environment to validate PostgreSQL migrations, seeded users, interview lifecycle behavior, and RabbitMQ fanout.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-19 21:58:24 +02:00
parent fd06fbbb5d
commit 75e3c73a61
35 changed files with 3621 additions and 58 deletions

View file

@ -1,7 +1,6 @@
package router
import (
"context"
"encoding/json"
"net/http"
"strings"
@ -13,10 +12,6 @@ import (
"github.com/danyel/go-guess/backend/internal/web/handler"
)
type contextKey string
const claimsKey contextKey = "claims"
func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler {
router := chi.NewRouter()
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
@ -27,6 +22,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
router.Post("/api/interviews/{token}/accept", h.AcceptInterview)
router.Put("/api/interviews/{token}/answers/{questionId}", h.SaveAnswer)
router.Post("/api/interviews/{token}/finish", h.FinishInterview)
router.Get("/api/participant-meetings/{token}", h.GetParticipantMeeting)
router.Group(func(protected chi.Router) {
protected.Use(authenticate(tokens))
protected.Get("/api/jobs", h.ListJobs)
@ -39,6 +35,20 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
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.Patch("/api/jobs/{id}/invitations/{invitationId}/outcome", h.SetInvitationOutcome)
protected.Get("/api/jobs/{id}/interviews", h.ListScheduledInterviews)
protected.Post("/api/jobs/{id}/interviews", h.CreateScheduledInterview)
protected.Get("/api/scheduled-interviews/{id}", h.GetScheduledInterview)
protected.Patch("/api/scheduled-interviews/{id}/status", h.UpdateScheduledInterviewStatus)
protected.Patch("/api/scheduled-interviews/{id}/document", h.UpdateScheduledInterviewDocument)
protected.Get("/api/scheduled-interviews/{id}/notes", h.ListInterviewNotes)
protected.Post("/api/scheduled-interviews/{id}/notes", h.CreateInterviewNote)
protected.Get("/api/scheduled-interviews/{id}/events", h.InterviewEvents)
protected.Get("/api/users", h.ListUsers)
protected.Post("/api/users", h.CreateUser)
protected.Get("/api/inbox", h.ListInbox)
protected.Patch("/api/inbox/{id}", h.UpdateInbox)
protected.Get("/api/calendar", h.ListCalendar)
protected.Get("/api/questions", h.ListQuestions)
protected.Post("/api/questions", h.CreateQuestion)
protected.Put("/api/questions/{id}", h.UpdateQuestion)
@ -72,7 +82,7 @@ func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims)))
next.ServeHTTP(w, r.WithContext(security.WithClaims(r.Context(), claims)))
})
}
}

View file

@ -1,12 +1,15 @@
package router
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/danyel/go-guess/backend/internal/eventbus"
"github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/service"
"github.com/danyel/go-guess/backend/internal/service/model"
@ -77,9 +80,87 @@ func (invitationStub) SaveAnswer(context.Context, string, uint, string) error {
func (invitationStub) Finish(context.Context, string) (model.Interview, error) {
return model.Interview{}, nil
}
func (invitationStub) SetOutcome(context.Context, uint, uint, string) (model.Invitation, error) {
return model.Invitation{}, nil
}
type userStub struct{}
func (userStub) List(context.Context) ([]model.User, error) { return nil, nil }
func (userStub) Create(context.Context, string, string, string) (model.User, error) {
return model.User{}, nil
}
type interviewStub struct{}
func (interviewStub) Create(
context.Context, uint, uint, model.ScheduledInterview, []uint,
) (model.ScheduledInterview, error) {
return model.ScheduledInterview{}, nil
}
func (interviewStub) List(context.Context, uint, uint) ([]model.ScheduledInterview, error) {
return nil, nil
}
func (interviewStub) Get(context.Context, uint, uint) (model.ScheduledInterview, error) {
return model.ScheduledInterview{}, nil
}
func (interviewStub) GetParticipantMeeting(context.Context, string) (model.ScheduledInterview, error) {
return model.ScheduledInterview{}, nil
}
func (interviewStub) UpdateStatus(context.Context, uint, uint, string) (model.ScheduledInterview, error) {
return model.ScheduledInterview{}, nil
}
func (interviewStub) UpdateDocument(context.Context, uint, uint, string) (model.ScheduledInterview, error) {
return model.ScheduledInterview{}, nil
}
func (interviewStub) ListNotes(context.Context, uint, uint) ([]model.InterviewNote, error) {
return nil, nil
}
func (interviewStub) CreateNote(context.Context, uint, uint, string) (model.InterviewNote, error) {
return model.InterviewNote{}, nil
}
func (interviewStub) ListInbox(context.Context, uint) ([]model.ScheduledInterview, error) {
return nil, nil
}
func (interviewStub) UpdateInbox(context.Context, uint, uint, string) (model.ScheduledInterview, error) {
return model.ScheduledInterview{}, nil
}
func (interviewStub) ListCalendar(context.Context, uint) ([]model.ScheduledInterview, error) {
return nil, nil
}
func (interviewStub) Subscribe(context.Context, uint, uint) (<-chan eventbus.InterviewNoteEvent, error) {
return make(chan eventbus.InterviewNoteEvent), nil
}
type interviewCapture struct {
interviewStub
userID uint
}
func (s *interviewCapture) Get(
_ context.Context, userID, interviewID uint,
) (model.ScheduledInterview, error) {
s.userID = userID
return model.ScheduledInterview{
ID: interviewID,
Attendees: []model.InterviewAttendee{{
ID: 3, UserID: userID, User: model.User{ID: userID, DisplayName: "Interviewer"},
}},
}, nil
}
func (s *interviewCapture) GetParticipantMeeting(
context.Context, string,
) (model.ScheduledInterview, error) {
return model.ScheduledInterview{
ID: 4, JobID: 5, Job: model.JobPosting{Title: "Backend Engineer"},
Participant: model.Participant{FirstName: "Candidate", LastName: "Person", Email: "private@example.com"},
SharedDocument: "candidate document",
}, nil
}
func TestHealthDoesNotRequireAuthentication(t *testing.T) {
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173")
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, userStub{}, interviewStub{}, 10, "http://localhost:5173")
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
router := New(h, tokens, "http://localhost:5173")
request := httptest.NewRequest(http.MethodGet, "/api/health", nil)
@ -91,7 +172,7 @@ func TestHealthDoesNotRequireAuthentication(t *testing.T) {
}
func TestProtectedRouteRequiresValidToken(t *testing.T) {
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173")
h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, userStub{}, interviewStub{}, 10, "http://localhost:5173")
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
router := New(h, tokens, "http://localhost:5173")
@ -100,7 +181,6 @@ func TestProtectedRouteRequiresValidToken(t *testing.T) {
if unauthorized.Code != http.StatusUnauthorized || unauthorized.Header().Get("Content-Type") != "application/json" {
t.Fatalf("expected JSON 401, got %d %q", unauthorized.Code, unauthorized.Header().Get("Content-Type"))
}
token, err := tokens.Issue(1, "interviewer@example.com", "interviewer")
if err != nil {
t.Fatal(err)
@ -113,3 +193,48 @@ func TestProtectedRouteRequiresValidToken(t *testing.T) {
t.Fatalf("expected 200, got %d", authorized.Code)
}
}
func TestProtectedInterviewUsesAuthenticatedUserID(t *testing.T) {
interviews := &interviewCapture{}
h := handler.New(
authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{},
userStub{}, interviews, 10, "http://localhost:5173",
)
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
api := New(h, tokens, "http://localhost:5173")
token, err := tokens.Issue(42, "co@example.com", "co_interviewer")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/api/scheduled-interviews/9", nil)
request.Header.Set("Authorization", "Bearer "+token)
response := httptest.NewRecorder()
api.ServeHTTP(response, request)
if response.Code != http.StatusOK || interviews.userID != 42 {
t.Fatalf("expected authenticated user 42, got status=%d user=%d", response.Code, interviews.userID)
}
}
func TestParticipantMeetingDoesNotExposeInternalCandidateData(t *testing.T) {
interviews := &interviewCapture{}
h := handler.New(
authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{},
userStub{}, interviews, 10, "http://localhost:5173",
)
api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173")
response := httptest.NewRecorder()
api.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/participant-meetings/token", nil))
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", response.Code)
}
body := response.Body.Bytes()
if bytes.Contains(body, []byte("private@example.com")) ||
bytes.Contains(body, []byte("referenceAnswer")) ||
bytes.Contains(body, []byte("notes")) {
t.Fatalf("public response leaked internal data: %s", body)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil || payload["sharedDocument"] != "candidate document" {
t.Fatalf("unexpected public response: %s", body)
}
}