feat: sync interview content in real time

Publish shared-document and note events through RabbitMQ, stream token-scoped document updates to participants, and update connected interviewer and participant browsers through resilient SSE subscriptions.

Add snapshots, heartbeats, reconnection behavior, proxy streaming configuration, privacy filtering, and end-to-end RabbitMQ coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-19 22:22:42 +02:00
parent e4fee06e2f
commit c050815900
16 changed files with 438 additions and 101 deletions

View file

@ -33,10 +33,11 @@ The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use
`internal/database/{repository,mapper,entity}`. Never expose GORM entities from `internal/database/{repository,mapper,entity}`. Never expose GORM entities from
handlers. handlers.
- `backend/cmd/seed` applies embedded environment-aware fixtures. - `backend/cmd/seed` applies embedded environment-aware fixtures.
- `backend/internal/eventbus` publishes persisted interview notes to the durable - `backend/internal/eventbus` publishes note and shared-document events to the
`go-guess.interview-notes` RabbitMQ fanout exchange. Each API instance consumes durable `go-guess.interview-events` RabbitMQ fanout exchange. Each API instance
through its own exclusive queue and distributes events to authenticated SSE consumes through its own exclusive queue and distributes interview-filtered
subscribers. events to authenticated interviewer SSE streams or token-authorized participant
streams.
- `frontend/src/app` owns application routing and providers, `src/features` groups - `frontend/src/app` owns application routing and providers, `src/features` groups
feature pages and components, `src/components` contains reusable UI, feature pages and components, `src/components` contains reusable UI,
`src/api/client.ts` is the typed HTTP boundary, `src/types` contains client `src/api/client.ts` is the typed HTTP boundary, `src/types` contains client

View file

@ -10,7 +10,7 @@ timed assessments followed by collaborative interviews.
- Go 1.27.1 HTTP API using Chi, GORM, PostgreSQL, Goose, bcrypt, and signed JWTs - Go 1.27.1 HTTP API using Chi, GORM, PostgreSQL, Goose, bcrypt, and signed JWTs
- React 19.3, TypeScript, and Vite - React 19.3, TypeScript, and Vite
- PostgreSQL 18 - PostgreSQL 18
- RabbitMQ 4 for live interview-note fanout - RabbitMQ 4 for live interview event fanout
## Run locally ## Run locally
@ -62,7 +62,7 @@ cd frontend && npm test -- src/app/App.test.tsx -t "filters jobs by title"
Docker must be available for `make test`. The integration suite starts PostgreSQL Docker must be available for `make test`. The integration suite starts PostgreSQL
18 and RabbitMQ 4 in disposable Testcontainers, applies every Goose migration, 18 and RabbitMQ 4 in disposable Testcontainers, applies every Goose migration,
loads deterministic test fixtures, exercises login and the complete assessment and loads deterministic test fixtures, exercises login and the complete assessment and
interview workflow, verifies live note fanout, and removes both containers interview workflow, verifies live note and document fanout, and removes both containers
afterward. afterward.
Start an isolated application stack populated with test data for manual UI testing: Start an isolated application stack populated with test data for manual UI testing:
@ -92,11 +92,11 @@ Completed assessments can be marked passed or failed. A passed candidate can be
scheduled for an interview with a date, location or meeting link, shared document, scheduled for an interview with a date, location or meeting link, shared document,
and selected co-interviewers. Each interviewer has an inbox for invitations and a and selected co-interviewers. Each interviewer has an inbox for invitations and a
calendar for accepted interviews. During a started interview, attendees can add calendar for accepted interviews. During a started interview, attendees can add
private notes; the API persists each note and publishes it through a durable private notes. Notes and shared-document changes are published through a durable
RabbitMQ fanout exchange so connected interviewer sessions receive it over an RabbitMQ fanout exchange and delivered over server-sent-event streams. Authenticated
authenticated server-sent-event stream. The candidate receives a separate opaque interviewer sessions receive both event types; the candidate's opaque meeting link
meeting link that exposes the schedule and shared document, but never private notes uses a token-authorized stream that receives shared-document updates but never
or assessment reference answers. private notes or assessment reference answers.
Development fixtures include `co-interviewer@go-guess.local` with password Development fixtures include `co-interviewer@go-guess.local` with password
`admin123`. Additional co-interviewers can be created from the Users screen. `admin123`. Additional co-interviewers can be created from the Users screen.

View file

@ -10,7 +10,7 @@ import (
amqp "github.com/rabbitmq/amqp091-go" amqp "github.com/rabbitmq/amqp091-go"
) )
const interviewExchange = "go-guess.interview-notes" const interviewExchange = "go-guess.interview-events"
type AMQPBus struct { type AMQPBus struct {
url string url string
@ -18,14 +18,14 @@ type AMQPBus struct {
connection *amqp.Connection connection *amqp.Connection
publisher *amqp.Channel publisher *amqp.Channel
consumer *amqp.Channel consumer *amqp.Channel
subscribers map[chan InterviewNoteEvent]struct{} subscribers map[chan InterviewEvent]struct{}
done chan struct{} done chan struct{}
closed bool closed bool
} }
func NewAMQPBus(url string) (*AMQPBus, error) { func NewAMQPBus(url string) (*AMQPBus, error) {
bus := &AMQPBus{ bus := &AMQPBus{
url: url, subscribers: make(map[chan InterviewNoteEvent]struct{}), done: make(chan struct{}), url: url, subscribers: make(map[chan InterviewEvent]struct{}), done: make(chan struct{}),
} }
deliveries, err := bus.connect() deliveries, err := bus.connect()
if err != nil { if err != nil {
@ -89,10 +89,10 @@ func (b *AMQPBus) connect() (<-chan amqp.Delivery, error) {
return deliveries, nil return deliveries, nil
} }
func (b *AMQPBus) PublishInterviewNote(ctx context.Context, event InterviewNoteEvent) error { func (b *AMQPBus) PublishInterviewEvent(ctx context.Context, event InterviewEvent) error {
body, err := json.Marshal(event) body, err := json.Marshal(event)
if err != nil { if err != nil {
return fmt.Errorf("encode interview note event: %w", err) return fmt.Errorf("encode interview event: %w", err)
} }
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
@ -114,11 +114,11 @@ func (b *AMQPBus) PublishInterviewNote(ctx context.Context, event InterviewNoteE
} }
deliveries, reconnectErr := b.connect() deliveries, reconnectErr := b.connect()
if reconnectErr != nil { if reconnectErr != nil {
return fmt.Errorf("publish interview note: %w; reconnect: %v", err, reconnectErr) return fmt.Errorf("publish interview event: %w; reconnect: %v", err, reconnectErr)
} }
go b.dispatch(deliveries) go b.dispatch(deliveries)
if err := b.publishConfirmed(ctx, body); err != nil { if err := b.publishConfirmed(ctx, body); err != nil {
return fmt.Errorf("publish interview note after reconnect: %w", err) return fmt.Errorf("publish interview event after reconnect: %w", err)
} }
return nil return nil
} }
@ -136,18 +136,18 @@ func (b *AMQPBus) publishConfirmed(ctx context.Context, body []byte) error {
return err return err
} }
if !acknowledged { if !acknowledged {
return errors.New("RabbitMQ rejected interview note") return errors.New("RabbitMQ rejected interview event")
} }
return nil return nil
} }
func (b *AMQPBus) SubscribeInterviewNotes(ctx context.Context) (<-chan InterviewNoteEvent, error) { func (b *AMQPBus) SubscribeInterviewEvents(ctx context.Context) (<-chan InterviewEvent, error) {
b.mu.Lock() b.mu.Lock()
if b.closed { if b.closed {
b.mu.Unlock() b.mu.Unlock()
return nil, ErrClosed return nil, ErrClosed
} }
ch := make(chan InterviewNoteEvent, 16) ch := make(chan InterviewEvent, 16)
b.subscribers[ch] = struct{}{} b.subscribers[ch] = struct{}{}
b.mu.Unlock() b.mu.Unlock()
go func() { go func() {
@ -171,7 +171,7 @@ func (b *AMQPBus) dispatch(deliveries <-chan amqp.Delivery) {
if !ok { if !ok {
return return
} }
var event InterviewNoteEvent var event InterviewEvent
if json.Unmarshal(delivery.Body, &event) != nil { if json.Unmarshal(delivery.Body, &event) != nil {
continue continue
} }
@ -180,6 +180,8 @@ func (b *AMQPBus) dispatch(deliveries <-chan amqp.Delivery) {
select { select {
case subscriber <- event: case subscriber <- event:
default: default:
delete(b.subscribers, subscriber)
close(subscriber)
} }
} }
b.mu.Unlock() b.mu.Unlock()

View file

@ -8,7 +8,6 @@ import (
) )
type InterviewNoteEvent struct { type InterviewNoteEvent struct {
InterviewID uint `json:"interviewId"`
ID uint `json:"id"` ID uint `json:"id"`
AuthorUserID uint `json:"authorUserId"` AuthorUserID uint `json:"authorUserId"`
AuthorName string `json:"authorName"` AuthorName string `json:"authorName"`
@ -16,9 +15,16 @@ type InterviewNoteEvent struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
type InterviewEvent struct {
Type string `json:"type"`
InterviewID uint `json:"interviewId"`
Note *InterviewNoteEvent `json:"note,omitempty"`
SharedDocument string `json:"sharedDocument,omitempty"`
}
type IEventBus interface { type IEventBus interface {
PublishInterviewNote(context.Context, InterviewNoteEvent) error PublishInterviewEvent(context.Context, InterviewEvent) error
SubscribeInterviewNotes(context.Context) (<-chan InterviewNoteEvent, error) SubscribeInterviewEvents(context.Context) (<-chan InterviewEvent, error)
Close() error Close() error
} }
@ -26,15 +32,15 @@ var ErrClosed = errors.New("event bus is closed")
type MemoryBus struct { type MemoryBus struct {
mu sync.RWMutex mu sync.RWMutex
subscribers map[chan InterviewNoteEvent]struct{} subscribers map[chan InterviewEvent]struct{}
closed bool closed bool
} }
func NewMemoryBus() *MemoryBus { func NewMemoryBus() *MemoryBus {
return &MemoryBus{subscribers: make(map[chan InterviewNoteEvent]struct{})} return &MemoryBus{subscribers: make(map[chan InterviewEvent]struct{})}
} }
func (b *MemoryBus) PublishInterviewNote(ctx context.Context, event InterviewNoteEvent) error { func (b *MemoryBus) PublishInterviewEvent(ctx context.Context, event InterviewEvent) error {
b.mu.RLock() b.mu.RLock()
defer b.mu.RUnlock() defer b.mu.RUnlock()
if b.closed { if b.closed {
@ -50,13 +56,13 @@ func (b *MemoryBus) PublishInterviewNote(ctx context.Context, event InterviewNot
return nil return nil
} }
func (b *MemoryBus) SubscribeInterviewNotes(ctx context.Context) (<-chan InterviewNoteEvent, error) { func (b *MemoryBus) SubscribeInterviewEvents(ctx context.Context) (<-chan InterviewEvent, error) {
b.mu.Lock() b.mu.Lock()
if b.closed { if b.closed {
b.mu.Unlock() b.mu.Unlock()
return nil, ErrClosed return nil, ErrClosed
} }
ch := make(chan InterviewNoteEvent, 16) ch := make(chan InterviewEvent, 16)
b.subscribers[ch] = struct{}{} b.subscribers[ch] = struct{}{}
b.mu.Unlock() b.mu.Unlock()
go func() { go func() {

View file

@ -3,6 +3,7 @@
package integration package integration
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"database/sql" "database/sql"
@ -313,13 +314,55 @@ func TestCompleteInterviewWorkflow(t *testing.T) {
requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/scheduled-interviews/%d/status", scheduled.ID), requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/scheduled-interviews/%d/status", scheduled.ID),
login.Token, map[string]any{"status": "started"}, http.StatusOK, &scheduled) login.Token, map[string]any{"status": "started"}, http.StatusOK, &scheduled)
subscriptionContext, cancelSubscriptions := context.WithCancel(context.Background()) streamContext, cancelStream := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelSubscriptions() defer cancelStream()
firstEvents, err := testBus.SubscribeInterviewNotes(subscriptionContext) streamRequest, err := http.NewRequestWithContext(
streamContext, http.MethodGet,
testServer.URL+"/api/participant-meetings/"+scheduled.CandidateToken+"/events", nil,
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
secondEvents, err := testBus.SubscribeInterviewNotes(subscriptionContext) streamResponse, err := http.DefaultClient.Do(streamRequest)
if err != nil {
t.Fatal(err)
}
defer streamResponse.Body.Close()
if streamResponse.StatusCode != http.StatusOK {
t.Fatalf("candidate event stream returned %d", streamResponse.StatusCode)
}
const updatedDocument = "Updated collaborative exercise"
requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/scheduled-interviews/%d/document", scheduled.ID),
login.Token, map[string]any{"sharedDocument": updatedDocument}, http.StatusOK, &scheduled)
var documentEvent eventbus.InterviewEvent
scanner := bufio.NewScanner(streamResponse.Body)
for scanner.Scan() {
if strings.HasPrefix(scanner.Text(), "data: ") {
if err := json.Unmarshal([]byte(strings.TrimPrefix(scanner.Text(), "data: ")), &documentEvent); err != nil {
t.Fatal(err)
}
if documentEvent.Type == "document.updated" &&
documentEvent.SharedDocument == updatedDocument {
break
}
}
}
if err := scanner.Err(); err != nil {
t.Fatal(err)
}
if documentEvent.Type != "document.updated" ||
documentEvent.InterviewID != scheduled.ID ||
documentEvent.SharedDocument != updatedDocument {
t.Fatalf("candidate received unexpected document event: %#v", documentEvent)
}
subscriptionContext, cancelSubscriptions := context.WithCancel(context.Background())
defer cancelSubscriptions()
firstEvents, err := testBus.SubscribeInterviewEvents(subscriptionContext)
if err != nil {
t.Fatal(err)
}
secondEvents, err := testBus.SubscribeInterviewEvents(subscriptionContext)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -331,10 +374,11 @@ func TestCompleteInterviewWorkflow(t *testing.T) {
if note.AuthorUserID != coInterviewer.ID || note.AuthorName != "Co Interviewer" { if note.AuthorUserID != coInterviewer.ID || note.AuthorName != "Co Interviewer" {
t.Fatalf("unexpected note response: %#v", note) t.Fatalf("unexpected note response: %#v", note)
} }
for index, events := range []<-chan eventbus.InterviewNoteEvent{firstEvents, secondEvents} { for index, events := range []<-chan eventbus.InterviewEvent{firstEvents, secondEvents} {
select { select {
case received := <-events: case received := <-events:
if received.ID != note.ID || received.AuthorUserID != coInterviewer.ID { if received.Type != "note.created" || received.Note == nil ||
received.Note.ID != note.ID || received.Note.AuthorUserID != coInterviewer.ID {
t.Fatalf("subscriber %d received unexpected RabbitMQ event: %#v", index+1, received) t.Fatalf("subscriber %d received unexpected RabbitMQ event: %#v", index+1, received)
} }
encoded, err := json.Marshal(received) encoded, err := json.Marshal(received)
@ -358,7 +402,7 @@ func TestCompleteInterviewWorkflow(t *testing.T) {
var meeting webmodel.ParticipantMeetingResponse var meeting webmodel.ParticipantMeetingResponse
body := requestJSON(t, http.MethodGet, "/api/participant-meetings/"+scheduled.CandidateToken, body := requestJSON(t, http.MethodGet, "/api/participant-meetings/"+scheduled.CandidateToken,
"", nil, http.StatusOK, &meeting) "", nil, http.StatusOK, &meeting)
if meeting.SharedDocument != "Collaborative notes" || if meeting.SharedDocument != updatedDocument ||
bytes.Contains(body, []byte("participantEmail")) || bytes.Contains(body, []byte("participantEmail")) ||
bytes.Contains(body, []byte("referenceAnswer")) || bytes.Contains(body, []byte("referenceAnswer")) ||
bytes.Contains(body, []byte(`"notes":`)) { bytes.Contains(body, []byte(`"notes":`)) {

View file

@ -30,7 +30,8 @@ type IScheduledInterviewService interface {
ListInbox(context.Context, uint) ([]model.ScheduledInterview, error) ListInbox(context.Context, uint) ([]model.ScheduledInterview, error)
UpdateInbox(context.Context, uint, uint, string) (model.ScheduledInterview, error) UpdateInbox(context.Context, uint, uint, string) (model.ScheduledInterview, error)
ListCalendar(context.Context, uint) ([]model.ScheduledInterview, error) ListCalendar(context.Context, uint) ([]model.ScheduledInterview, error)
Subscribe(context.Context, uint, uint) (<-chan eventbus.InterviewNoteEvent, error) Subscribe(context.Context, uint, uint) (<-chan eventbus.InterviewEvent, error)
SubscribeParticipant(context.Context, string) (<-chan eventbus.InterviewEvent, error)
} }
type UserService struct { type UserService struct {
@ -174,7 +175,16 @@ func (s *ScheduledInterviewService) UpdateDocument(
if _, err := s.Get(ctx, userID, interviewID); err != nil { if _, err := s.Get(ctx, userID, interviewID); err != nil {
return model.ScheduledInterview{}, err return model.ScheduledInterview{}, err
} }
return s.store.UpdateScheduledInterviewDocument(ctx, interviewID, document) updated, err := s.store.UpdateScheduledInterviewDocument(ctx, interviewID, document)
if err != nil {
return model.ScheduledInterview{}, err
}
if err := s.bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
Type: "document.updated", InterviewID: interviewID, SharedDocument: updated.SharedDocument,
}); err != nil {
return model.ScheduledInterview{}, fmt.Errorf("publish persisted interview document: %w", err)
}
return updated, nil
} }
func (s *ScheduledInterviewService) ListNotes( func (s *ScheduledInterviewService) ListNotes(
@ -209,9 +219,12 @@ func (s *ScheduledInterviewService) CreateNote(
} }
return model.InterviewNote{}, err return model.InterviewNote{}, err
} }
if err := s.bus.PublishInterviewNote(ctx, eventbus.InterviewNoteEvent{ if err := s.bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
InterviewID: note.InterviewID, ID: note.ID, AuthorUserID: note.AuthorID, Type: "note.created", InterviewID: note.InterviewID,
AuthorName: note.Author.DisplayName, Body: note.Body, CreatedAt: note.CreatedAt, Note: &eventbus.InterviewNoteEvent{
ID: note.ID, AuthorUserID: note.AuthorID, AuthorName: note.Author.DisplayName,
Body: note.Body, CreatedAt: note.CreatedAt,
},
}); err != nil { }); err != nil {
return model.InterviewNote{}, fmt.Errorf("publish persisted interview note: %w", err) return model.InterviewNote{}, fmt.Errorf("publish persisted interview note: %w", err)
} }
@ -244,11 +257,64 @@ func (s *ScheduledInterviewService) ListCalendar(
func (s *ScheduledInterviewService) Subscribe( func (s *ScheduledInterviewService) Subscribe(
ctx context.Context, userID, interviewID uint, ctx context.Context, userID, interviewID uint,
) (<-chan eventbus.InterviewNoteEvent, error) { ) (<-chan eventbus.InterviewEvent, error) {
if _, err := s.Get(ctx, userID, interviewID); err != nil { if _, err := s.Get(ctx, userID, interviewID); err != nil {
return nil, err return nil, err
} }
return s.bus.SubscribeInterviewNotes(ctx) return s.subscribeToInterview(ctx, interviewID, false)
}
func (s *ScheduledInterviewService) SubscribeParticipant(
ctx context.Context, token string,
) (<-chan eventbus.InterviewEvent, error) {
interview, err := s.GetParticipantMeeting(ctx, token)
if err != nil {
return nil, err
}
return s.subscribeToInterview(ctx, interview.ID, true)
}
func (s *ScheduledInterviewService) subscribeToInterview(
ctx context.Context, interviewID uint, documentsOnly bool,
) (<-chan eventbus.InterviewEvent, error) {
events, err := s.bus.SubscribeInterviewEvents(ctx)
if err != nil {
return nil, err
}
current, err := s.store.GetScheduledInterview(ctx, interviewID)
if err != nil {
return nil, err
}
filtered := make(chan eventbus.InterviewEvent, 16)
filtered <- eventbus.InterviewEvent{
Type: "document.updated", InterviewID: interviewID,
SharedDocument: current.SharedDocument,
}
go func() {
defer close(filtered)
for {
select {
case <-ctx.Done():
return
case event, ok := <-events:
if !ok {
return
}
if event.InterviewID != interviewID {
continue
}
if documentsOnly && event.Type != "document.updated" {
continue
}
select {
case filtered <- event:
case <-ctx.Done():
return
}
}
}
}()
return filtered, nil
} }
func isAttendee(value model.ScheduledInterview, userID uint) bool { func isAttendee(value model.ScheduledInterview, userID uint) bool {

View file

@ -391,7 +391,7 @@ func TestCreateNotePersistsThenPublishesSafeEvent(t *testing.T) {
}, },
} }
bus := eventbus.NewMemoryBus() bus := eventbus.NewMemoryBus()
events, err := bus.SubscribeInterviewNotes(context.Background()) events, err := bus.SubscribeInterviewEvents(context.Background())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -401,11 +401,70 @@ func TestCreateNotePersistsThenPublishesSafeEvent(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
event := <-events event := <-events
if note.Body != "useful note" || event.ID != note.ID || event.AuthorName != "Ada" { if note.Body != "useful note" || event.Type != "note.created" || event.Note == nil ||
event.Note.ID != note.ID || event.Note.AuthorName != "Ada" {
t.Fatalf("unexpected note/event: %#v %#v", note, event) t.Fatalf("unexpected note/event: %#v %#v", note, event)
} }
} }
func TestUpdateDocumentPublishesEvent(t *testing.T) {
store := &scheduledStore{
fakeStore: &fakeStore{},
interview: model.ScheduledInterview{
ID: 9, Status: "scheduled", Attendees: []model.InterviewAttendee{{UserID: 1}},
},
}
bus := eventbus.NewMemoryBus()
events, err := bus.SubscribeInterviewEvents(context.Background())
if err != nil {
t.Fatal(err)
}
interviews := NewScheduledInterviewService(store, bus)
updated, err := interviews.UpdateDocument(context.Background(), 1, 9, "New agenda")
if err != nil {
t.Fatal(err)
}
event := <-events
if updated.SharedDocument != "New agenda" || event.Type != "document.updated" ||
event.InterviewID != 9 || event.SharedDocument != "New agenda" {
t.Fatalf("unexpected document/event: %#v %#v", updated, event)
}
}
func TestParticipantSubscriptionFiltersPrivateNotes(t *testing.T) {
bus := eventbus.NewMemoryBus()
interviews := NewScheduledInterviewService(&fakeStore{}, bus)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events, err := interviews.SubscribeParticipant(ctx, "candidate-token")
if err != nil {
t.Fatal(err)
}
initial := <-events
if initial.Type != "document.updated" {
t.Fatalf("participant did not receive initial document snapshot: %#v", initial)
}
if err := bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
Type: "note.created", InterviewID: 0,
Note: &eventbus.InterviewNoteEvent{ID: 4, Body: "private"},
}); err != nil {
t.Fatal(err)
}
if err := bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
Type: "document.updated", InterviewID: 0, SharedDocument: "candidate-safe",
}); err != nil {
t.Fatal(err)
}
select {
case event := <-events:
if event.Type != "document.updated" || event.SharedDocument != "candidate-safe" {
t.Fatalf("participant received unexpected event: %#v", event)
}
case <-time.After(time.Second):
t.Fatal("participant did not receive document event")
}
}
func TestUserCreationHashesPasswordAndUsesCoInterviewerRole(t *testing.T) { func TestUserCreationHashesPasswordAndUsesCoInterviewerRole(t *testing.T) {
store := &fakeStore{} store := &fakeStore{}
user, err := NewUserService(store).Create(context.Background(), " CO@Example.com ", "password1", " Ada ") user, err := NewUserService(store).Create(context.Background(), " CO@Example.com ", "password1", " Ada ")

View file

@ -15,6 +15,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/danyel/go-guess/backend/internal/database/repository" "github.com/danyel/go-guess/backend/internal/database/repository"
"github.com/danyel/go-guess/backend/internal/eventbus"
"github.com/danyel/go-guess/backend/internal/security" "github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/service" "github.com/danyel/go-guess/backend/internal/service"
servicemodel "github.com/danyel/go-guess/backend/internal/service/model" servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
@ -511,6 +512,20 @@ func (h *Handler) InterviewEvents(w http.ResponseWriter, r *http.Request) {
if handleServiceError(w, r, err) { if handleServiceError(w, r, err) {
return return
} }
streamInterviewEvents(w, r, events)
}
func (h *Handler) ParticipantMeetingEvents(w http.ResponseWriter, r *http.Request) {
events, err := h.interviews.SubscribeParticipant(r.Context(), chi.URLParam(r, "token"))
if handleServiceError(w, r, err) {
return
}
streamInterviewEvents(w, r, events)
}
func streamInterviewEvents(
w http.ResponseWriter, r *http.Request, events <-chan eventbus.InterviewEvent,
) {
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { if !ok {
internalError(w, r, errors.New("streaming is unsupported")) internalError(w, r, errors.New("streaming is unsupported"))
@ -519,24 +534,29 @@ func (h *Handler) InterviewEvents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
flusher.Flush() flusher.Flush()
heartbeat := time.NewTicker(20 * time.Second)
defer heartbeat.Stop()
for { for {
select { select {
case <-r.Context().Done(): case <-r.Context().Done():
return return
case <-heartbeat.C:
if _, err := io.WriteString(w, ": keepalive\n\n"); err != nil {
return
}
flusher.Flush()
case event, ok := <-events: case event, ok := <-events:
if !ok { if !ok {
return return
} }
if event.InterviewID != idParam(r) {
continue
}
payload, err := json.Marshal(event) payload, err := json.Marshal(event)
if err != nil { if err != nil {
return return
} }
if _, err := fmt.Fprintf(w, "event: interview-note\ndata: %s\n\n", payload); err != nil { if _, err := fmt.Fprintf(w, "event: interview-event\ndata: %s\n\n", payload); err != nil {
return return
} }
flusher.Flush() flusher.Flush()

View file

@ -23,6 +23,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
router.Put("/api/interviews/{token}/answers/{questionId}", h.SaveAnswer) router.Put("/api/interviews/{token}/answers/{questionId}", h.SaveAnswer)
router.Post("/api/interviews/{token}/finish", h.FinishInterview) router.Post("/api/interviews/{token}/finish", h.FinishInterview)
router.Get("/api/participant-meetings/{token}", h.GetParticipantMeeting) router.Get("/api/participant-meetings/{token}", h.GetParticipantMeeting)
router.Get("/api/participant-meetings/{token}/events", h.ParticipantMeetingEvents)
router.Group(func(protected chi.Router) { router.Group(func(protected chi.Router) {
protected.Use(authenticate(tokens)) protected.Use(authenticate(tokens))
protected.Get("/api/jobs", h.ListJobs) protected.Get("/api/jobs", h.ListJobs)

View file

@ -128,8 +128,11 @@ func (interviewStub) UpdateInbox(context.Context, uint, uint, string) (model.Sch
func (interviewStub) ListCalendar(context.Context, uint) ([]model.ScheduledInterview, error) { func (interviewStub) ListCalendar(context.Context, uint) ([]model.ScheduledInterview, error) {
return nil, nil return nil, nil
} }
func (interviewStub) Subscribe(context.Context, uint, uint) (<-chan eventbus.InterviewNoteEvent, error) { func (interviewStub) Subscribe(context.Context, uint, uint) (<-chan eventbus.InterviewEvent, error) {
return make(chan eventbus.InterviewNoteEvent), nil return make(chan eventbus.InterviewEvent), nil
}
func (interviewStub) SubscribeParticipant(context.Context, string) (<-chan eventbus.InterviewEvent, error) {
return make(chan eventbus.InterviewEvent), nil
} }
type interviewCapture struct { type interviewCapture struct {

View file

@ -71,6 +71,7 @@ Supported routes:
- `GET|POST /api/scheduled-interviews/:id/notes` - `GET|POST /api/scheduled-interviews/:id/notes`
- `GET /api/scheduled-interviews/:id/events` (authenticated fetch-based SSE) - `GET /api/scheduled-interviews/:id/events` (authenticated fetch-based SSE)
- `GET /api/participant-meetings/:token` (public) - `GET /api/participant-meetings/:token` (public)
- `GET /api/participant-meetings/:token/events` (token-authorized SSE)
Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`, Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`,
`contactInfo`, `photo`, and `cv`. `contactInfo`, `photo`, and `cv`.
@ -96,9 +97,9 @@ The authenticated workspace also includes:
- **Inbox** for accepting or declining interview assignments. - **Inbox** for accepting or declining interview assignments.
- **Calendar** for interviews grouped by date. - **Calendar** for interviews grouped by date.
- **Interviewer sessions** for changing status, editing prominent shared documentation, and - **Interviewer sessions** for changing status, editing prominent shared documentation, and
posting private notes while an interview is started. Notes arrive live through an authenticated posting private notes while an interview is started. Notes and shared-document changes arrive
streaming `fetch`; the app intentionally does not use `EventSource`, because the request needs live through an authenticated streaming `fetch`; the app intentionally does not use
the JWT authorization header. `EventSource`, because the request needs the JWT authorization header.
Participants open the generated `/participant/:invitationId` URL without signing in. The opaque 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 invitation ID authorizes the existing `/api/interviews/:token` API. Code-review questions are shown
@ -108,8 +109,9 @@ derived from the backend `acceptedAt` timestamp plus the job duration so refresh
it. Questions stay hidden until the participant explicitly accepts the invitation. it. Questions stay hidden until the participant explicitly accepts the invitation.
Scheduled candidates use `/participant/meeting/:token`. This public view shows the interview date, Scheduled candidates use `/participant/meeting/:token`. This public view shows the interview date,
location, status, and read-only shared documentation. It never renders interviewer notes or location, status, and read-only shared documentation. A token-authorized event stream updates the
assessment reference answers. document immediately when an interviewer saves it in another browser. The participant view never
receives interviewer notes or assessment reference answers.
## Container ## Container

View file

@ -12,6 +12,9 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
} }
location / { location / {

View file

@ -6,6 +6,7 @@ import type {
CreateQuestionInput, CreateQuestionInput,
CreateScheduledInterviewInput, CreateScheduledInterviewInput,
Interview, Interview,
InterviewEvent,
Invitation, Invitation,
Job, Job,
JobStatus, JobStatus,
@ -97,6 +98,56 @@ async function requestBlob(path: string): Promise<Blob> {
return response.blob() return response.blob()
} }
async function subscribeToInterviewEvents(
path: string,
onEvent: (event: InterviewEvent) => void,
signal: AbortSignal,
protectedRequest: boolean,
onConnectionError?: (error: unknown) => void,
) {
while (!signal.aborted) {
try {
const token = authStorage.token()
const response = await fetch(`/api${path}`, {
headers: {
Accept: 'text/event-stream',
...(protectedRequest && token ? { Authorization: 'Bearer ' + token } : {}),
},
signal,
})
if (!response.ok || !response.body)
throw new ApiError(`Live updates failed with status ${response.status}`, response.status)
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (!signal.aborted) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n')
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const message = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const data = message
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n')
if (data) onEvent(JSON.parse(data) as InterviewEvent)
boundary = buffer.indexOf('\n\n')
}
}
} catch (error) {
if (signal.aborted) return
if (error instanceof ApiError && error.status >= 400 && error.status < 500) throw error
onConnectionError?.(error)
}
if (!signal.aborted) {
await new Promise((resolve) => window.setTimeout(resolve, 1000))
}
}
}
export const api = { export const api = {
login: (email: string, password: string) => login: (email: string, password: string) =>
request<AuthResponse>( request<AuthResponse>(
@ -226,44 +277,35 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ body }), body: JSON.stringify({ body }),
}), }),
subscribe: async (id: number, onNote: (note: InterviewNote) => void, signal: AbortSignal) => { subscribe: (
const token = authStorage.token() id: number,
const response = await fetch(`/api/scheduled-interviews/${id}/events`, { onEvent: (event: InterviewEvent) => void,
headers: { signal: AbortSignal,
Accept: 'text/event-stream', onConnectionError?: (error: unknown) => void,
...(token ? { Authorization: 'Bearer ' + token } : {}), ) =>
}, subscribeToInterviewEvents(
`/scheduled-interviews/${id}/events`,
onEvent,
signal, signal,
}) true,
if (!response.ok || !response.body) onConnectionError,
throw new ApiError(`Live updates failed with status ${response.status}`, response.status) ),
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (!signal.aborted) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n')
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const message = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const data = message
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n')
if (data) {
const event = JSON.parse(data) as InterviewNote | { note: InterviewNote }
onNote('note' in event ? event.note : event)
}
boundary = buffer.indexOf('\n\n')
}
}
},
}, },
participantMeetings: { participantMeetings: {
get: (token: string) => get: (token: string) =>
request<ParticipantMeeting>(`/participant-meetings/${token}`, {}, false), request<ParticipantMeeting>(`/participant-meetings/${token}`, {}, false),
subscribe: (
token: string,
onEvent: (event: InterviewEvent) => void,
signal: AbortSignal,
onConnectionError?: (error: unknown) => void,
) =>
subscribeToInterviewEvents(
`/participant-meetings/${token}/events`,
onEvent,
signal,
false,
onConnectionError,
),
}, },
} }

View file

@ -853,7 +853,17 @@ describe('Go Guess frontend', () => {
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
controller.enqueue( controller.enqueue(
new TextEncoder().encode(`event: note\ndata: ${JSON.stringify(liveNote)}\n\n`), new TextEncoder().encode(
`event: interview-event\ndata: ${JSON.stringify({
type: 'note.created',
interviewId: 80,
note: liveNote,
})}\n\nevent: interview-event\ndata: ${JSON.stringify({
type: 'document.updated',
interviewId: 80,
sharedDocument: 'Remote agenda',
})}\n\n`,
),
) )
controller.close() controller.close()
}, },
@ -867,6 +877,7 @@ describe('Go Guess frontend', () => {
}) })
renderApp('/scheduled-interviews/80') renderApp('/scheduled-interviews/80')
expect(await screen.findByText('Strong system design answer.')).toBeInTheDocument() expect(await screen.findByText('Strong system design answer.')).toBeInTheDocument()
expect(screen.getByLabelText('Shared documentation')).toHaveValue('Remote agenda')
await user.click(screen.getByRole('button', { name: 'Copy participant interview link' })) await user.click(screen.getByRole('button', { name: 'Copy participant interview link' }))
expect(writeText).toHaveBeenCalledWith('/participant/meeting/meeting-token') expect(writeText).toHaveBeenCalledWith('/participant/meeting/meeting-token')
expect( expect(
@ -903,11 +914,30 @@ describe('Go Guess frontend', () => {
status: 'scheduled', status: 'scheduled',
sharedDocument: 'Please prepare the architecture exercise.', sharedDocument: 'Please prepare the architecture exercise.',
}) })
if (String(input) === '/api/participant-meetings/meeting-token/events') {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
`event: interview-event\ndata: ${JSON.stringify({
type: 'document.updated',
interviewId: 80,
sharedDocument: 'The architecture exercise changed live.',
})}\n\n`,
),
)
controller.close()
},
})
return Promise.resolve(
new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }),
)
}
throw new Error(`Unexpected request: ${String(input)}`) throw new Error(`Unexpected request: ${String(input)}`)
}) })
renderApp('/participant/meeting/meeting-token') renderApp('/participant/meeting/meeting-token')
expect(await screen.findByRole('heading', { name: job.title })).toBeInTheDocument() expect(await screen.findByRole('heading', { name: job.title })).toBeInTheDocument()
expect(screen.getByText('Please prepare the architecture exercise.')).toBeInTheDocument() expect(await screen.findByText('The architecture exercise changed live.')).toBeInTheDocument()
expect(screen.queryByText(/live notes/i)).not.toBeInTheDocument() expect(screen.queryByText(/live notes/i)).not.toBeInTheDocument()
expect(screen.queryByText(question.referenceAnswer)).not.toBeInTheDocument() expect(screen.queryByText(question.referenceAnswer)).not.toBeInTheDocument()
}) })

View file

@ -146,6 +146,7 @@ export function ScheduledInterviewPage() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [linkCopied, setLinkCopied] = useState(false) const [linkCopied, setLinkCopied] = useState(false)
const loadedInterviewID = interview?.id
useEffect(() => { useEffect(() => {
let active = true let active = true
@ -163,23 +164,34 @@ export function ScheduledInterviewPage() {
}, [id]) }, [id])
useEffect(() => { useEffect(() => {
if (interview?.status !== 'started') return if (!loadedInterviewID) return
const controller = new AbortController() const controller = new AbortController()
void api.scheduledInterviews void api.scheduledInterviews
.subscribe( .subscribe(
id, id,
(note) => { (event) => {
if (event.type === 'note.created') {
setNotes((current) => setNotes((current) =>
current.some((item) => item.id === note.id) ? current : [...current, note], current.some((item) => item.id === event.note.id)
? current
: [...current, event.note],
) )
}
if (event.type === 'document.updated') {
setDocument(event.sharedDocument)
setInterview((current) =>
current ? { ...current, sharedDocument: event.sharedDocument } : current,
)
}
}, },
controller.signal, controller.signal,
(value) => setError(message(value)),
) )
.catch((value) => { .catch((value) => {
if (!controller.signal.aborted) setError(message(value)) if (!controller.signal.aborted) setError(message(value))
}) })
return () => controller.abort() return () => controller.abort()
}, [id, interview?.status]) }, [id, loadedInterviewID])
async function setStatus(status: string) { async function setStatus(status: string) {
setError('') setError('')
@ -363,10 +375,44 @@ export function ParticipantMeetingPage() {
const [meeting, setMeeting] = useState<ParticipantMeeting>() const [meeting, setMeeting] = useState<ParticipantMeeting>()
const [error, setError] = useState('') const [error, setError] = useState('')
useEffect(() => { useEffect(() => {
let active = true
let latestDocument: string | undefined
const controller = new AbortController()
void api.participantMeetings
.subscribe(
token,
(event) => {
if (event.type !== 'document.updated') return
latestDocument = event.sharedDocument
setMeeting((current) =>
current ? { ...current, sharedDocument: event.sharedDocument } : current,
)
},
controller.signal,
(value) => {
if (active) setError(message(value))
},
)
.catch((value) => {
if (active && !controller.signal.aborted) setError(message(value))
})
void api.participantMeetings void api.participantMeetings
.get(token) .get(token)
.then(setMeeting) .then((value) => {
.catch((value) => setError(message(value))) if (active) {
setMeeting({
...value,
sharedDocument: latestDocument ?? value.sharedDocument,
})
}
})
.catch((value) => {
if (active) setError(message(value))
})
return () => {
active = false
controller.abort()
}
}, [token]) }, [token])
return ( return (
<main className="public-interview"> <main className="public-interview">

View file

@ -90,6 +90,18 @@ export interface InterviewNote {
createdAt: string createdAt: string
} }
export type InterviewEvent =
| {
type: 'note.created'
interviewId: number
note: InterviewNote
}
| {
type: 'document.updated'
interviewId: number
sharedDocument: string
}
export interface ScheduledInterview { export interface ScheduledInterview {
id: number id: number
jobId: number jobId: number