feat: add entity-scoped application events

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-20 12:29:11 +02:00
parent 3364eacbcc
commit e787359c54
40 changed files with 2143 additions and 1437 deletions

View file

@ -166,7 +166,16 @@ func (s *ScheduledInterviewService) UpdateStatus(
if !valid {
return model.ScheduledInterview{}, fmt.Errorf("%w: invalid interview status transition", ErrInvalidState)
}
return s.store.UpdateScheduledInterviewStatus(ctx, interviewID, status)
updated, err := s.store.UpdateScheduledInterviewStatus(ctx, interviewID, status)
if err != nil {
return model.ScheduledInterview{}, err
}
if err := s.bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
Type: "status.updated", InterviewID: interviewID, Status: updated.Status,
}); err != nil {
return model.ScheduledInterview{}, fmt.Errorf("publish persisted interview status: %w", err)
}
return updated, nil
}
func (s *ScheduledInterviewService) UpdateDocument(
@ -243,10 +252,24 @@ func (s *ScheduledInterviewService) UpdateInbox(
if status != "accepted" && status != "declined" {
return model.ScheduledInterview{}, fmt.Errorf("%w: attendee status must be accepted or declined", ErrValidation)
}
if _, err := s.store.UpdateAttendeeStatus(ctx, interviewID, userID, status); err != nil {
attendee, err := s.store.UpdateAttendeeStatus(ctx, interviewID, userID, status)
if err != nil {
return model.ScheduledInterview{}, err
}
return s.Get(ctx, userID, interviewID)
updated, err := s.Get(ctx, userID, interviewID)
if err != nil {
return model.ScheduledInterview{}, err
}
if err := s.bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
Type: "attendee.updated", InterviewID: interviewID,
Attendee: &eventbus.InterviewAttendeeEvent{
UserID: attendee.UserID, DisplayName: attendee.User.DisplayName,
Email: attendee.User.Email, Status: attendee.Status,
},
}); err != nil {
return model.ScheduledInterview{}, fmt.Errorf("publish persisted attendee status: %w", err)
}
return updated, nil
}
func (s *ScheduledInterviewService) ListCalendar(
@ -303,7 +326,7 @@ func (s *ScheduledInterviewService) subscribeToInterview(
if event.InterviewID != interviewID {
continue
}
if documentsOnly && event.Type != "document.updated" {
if documentsOnly && event.Type != "document.updated" && event.Type != "status.updated" {
continue
}
select {

View file

@ -34,6 +34,7 @@ type scheduledStore struct {
createdIDs []uint
status string
document string
attendee model.InterviewAttendee
}
func (f *scheduledStore) GetInvitation(context.Context, uint, uint) (model.Invitation, error) {
@ -68,6 +69,20 @@ func (f *scheduledStore) UpdateScheduledInterviewDocument(
f.interview.SharedDocument = document
return f.interview, nil
}
func (f *scheduledStore) UpdateAttendeeStatus(
_ context.Context, _, userID uint, status string,
) (model.InterviewAttendee, error) {
f.attendee = model.InterviewAttendee{
UserID: userID, Status: status,
User: model.User{ID: userID, DisplayName: "Ada", Email: "ada@example.com"},
}
for index := range f.interview.Attendees {
if f.interview.Attendees[index].UserID == userID {
f.interview.Attendees[index] = f.attendee
}
}
return f.attendee, nil
}
func (f *scheduledStore) CreateInterviewNote(
_ context.Context, value model.InterviewNote,
) (model.InterviewNote, error) {
@ -414,6 +429,7 @@ func TestUpdateDocumentPublishesEvent(t *testing.T) {
ID: 9, Status: "scheduled", Attendees: []model.InterviewAttendee{{UserID: 1}},
},
}
bus := eventbus.NewMemoryBus()
events, err := bus.SubscribeInterviewEvents(context.Background())
if err != nil {
@ -431,6 +447,36 @@ func TestUpdateDocumentPublishesEvent(t *testing.T) {
}
}
func TestStatusAndAttendeeUpdatesPublishEvents(t *testing.T) {
store := &scheduledStore{
fakeStore: &fakeStore{},
interview: model.ScheduledInterview{
ID: 9, Status: "scheduled",
Attendees: []model.InterviewAttendee{{UserID: 1, Status: "pending"}},
},
}
bus := eventbus.NewMemoryBus()
events, err := bus.SubscribeInterviewEvents(context.Background())
if err != nil {
t.Fatal(err)
}
interviews := NewScheduledInterviewService(store, bus)
if _, err := interviews.UpdateStatus(context.Background(), 1, 9, "started"); err != nil {
t.Fatal(err)
}
if event := <-events; event.Type != "status.updated" || event.Status != "started" {
t.Fatalf("unexpected status event: %#v", event)
}
store.interview.Status = "scheduled"
if _, err := interviews.UpdateInbox(context.Background(), 1, 9, "accepted"); err != nil {
t.Fatal(err)
}
if event := <-events; event.Type != "attendee.updated" || event.Attendee == nil ||
event.Attendee.UserID != 1 || event.Attendee.Status != "accepted" {
t.Fatalf("unexpected attendee event: %#v", event)
}
}
func TestParticipantSubscriptionFiltersPrivateNotes(t *testing.T) {
bus := eventbus.NewMemoryBus()
interviews := NewScheduledInterviewService(&fakeStore{}, bus)
@ -455,14 +501,27 @@ func TestParticipantSubscriptionFiltersPrivateNotes(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
if err := bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
Type: "status.updated", InterviewID: 0, Status: "started",
}); 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)
t.Fatalf("participant received unexpected document event: %#v", event)
}
case <-time.After(time.Second):
t.Fatal("participant did not receive document event")
}
select {
case event := <-events:
if event.Type != "status.updated" || event.Status != "started" {
t.Fatalf("participant received unexpected event: %#v", event)
}
case <-time.After(time.Second):
t.Fatal("participant did not receive status event")
}
}
func TestUserCreationHashesPasswordAndUsesCoInterviewerRole(t *testing.T) {