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:
parent
e4fee06e2f
commit
c050815900
16 changed files with 438 additions and 101 deletions
|
|
@ -30,7 +30,8 @@ type IScheduledInterviewService interface {
|
|||
ListInbox(context.Context, uint) ([]model.ScheduledInterview, error)
|
||||
UpdateInbox(context.Context, uint, uint, string) (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 {
|
||||
|
|
@ -174,7 +175,16 @@ func (s *ScheduledInterviewService) UpdateDocument(
|
|||
if _, err := s.Get(ctx, userID, interviewID); err != nil {
|
||||
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(
|
||||
|
|
@ -209,9 +219,12 @@ func (s *ScheduledInterviewService) CreateNote(
|
|||
}
|
||||
return model.InterviewNote{}, err
|
||||
}
|
||||
if err := s.bus.PublishInterviewNote(ctx, eventbus.InterviewNoteEvent{
|
||||
InterviewID: note.InterviewID, ID: note.ID, AuthorUserID: note.AuthorID,
|
||||
AuthorName: note.Author.DisplayName, Body: note.Body, CreatedAt: note.CreatedAt,
|
||||
if err := s.bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
|
||||
Type: "note.created", InterviewID: note.InterviewID,
|
||||
Note: &eventbus.InterviewNoteEvent{
|
||||
ID: note.ID, AuthorUserID: note.AuthorID, AuthorName: note.Author.DisplayName,
|
||||
Body: note.Body, CreatedAt: note.CreatedAt,
|
||||
},
|
||||
}); err != nil {
|
||||
return model.InterviewNote{}, fmt.Errorf("publish persisted interview note: %w", err)
|
||||
}
|
||||
|
|
@ -244,11 +257,64 @@ func (s *ScheduledInterviewService) ListCalendar(
|
|||
|
||||
func (s *ScheduledInterviewService) Subscribe(
|
||||
ctx context.Context, userID, interviewID uint,
|
||||
) (<-chan eventbus.InterviewNoteEvent, error) {
|
||||
) (<-chan eventbus.InterviewEvent, error) {
|
||||
if _, err := s.Get(ctx, userID, interviewID); err != nil {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -391,7 +391,7 @@ func TestCreateNotePersistsThenPublishesSafeEvent(t *testing.T) {
|
|||
},
|
||||
}
|
||||
bus := eventbus.NewMemoryBus()
|
||||
events, err := bus.SubscribeInterviewNotes(context.Background())
|
||||
events, err := bus.SubscribeInterviewEvents(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -401,11 +401,70 @@ func TestCreateNotePersistsThenPublishesSafeEvent(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
store := &fakeStore{}
|
||||
user, err := NewUserService(store).Create(context.Background(), " CO@Example.com ", "password1", " Ada ")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue