feat(activity): per-tenant audit feed in notification dropdown

activity_events records booking lifecycle transitions with the actor
behind each change — staff (dashboard), customer (self-service), or
system (auto-promotion). The notification dropdown leads with the real
feed instead of derived recent-booking guesses.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 16:06:13 +02:00
1 parent e5cb9bd543
commit d86cba61e3
18 files changed
+634 -28

No files matched your search

@@ -0,0 +1,92 @@
package bookings
import (
"context"
"testing"
"bookra/apps/backend/internal/db"
)
const activityTenantID = "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
var activitySessionID = "4bf74c12-44dd-45ca-86bb-b104f16f2435"
func seedSessionBooking(t *testing.T, repo *db.MemoryRepository, name, status string) db.BookingRecord {
t.Helper()
created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: activityTenantID,
ClassSessionID: &activitySessionID,
CustomerName: name,
CustomerEmail: name + "@example.com",
Status: status,
Reference: "BK-" + name,
ManageToken: "tok-" + name,
})
if err != nil {
t.Fatalf("seed booking: %v", err)
}
booking, err := repo.GetBookingByReference(context.Background(), created.Reference)
if err != nil {
t.Fatalf("get booking: %v", err)
}
return booking
}
func TestCustomerCancelWritesActivityEvent(t *testing.T) {
repo := db.NewMemoryRepository()
booking := newBookingFixture(t, repo)
svc := NewCustomerService(repo, nil, "http://localhost:3000")
if err := svc.CancelBooking(context.Background(), booking.Reference, "secret-manage-token"); err != nil {
t.Fatalf("cancel booking: %v", err)
}
events, err := repo.ListActivityEvents(context.Background(), activityTenantID, 1)
if err != nil {
t.Fatalf("list activity: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
event := events[0]
if event.Action != "booking.cancelled" {
t.Fatalf("expected booking.cancelled, got %s", event.Action)
}
if event.ActorKind != "customer" || event.ActorName != "Test Customer" {
t.Fatalf("expected customer actor Test Customer, got %s %q", event.ActorKind, event.ActorName)
}
if event.EntityID == nil || *event.EntityID != booking.ID {
t.Fatalf("expected entity id %s, got %v", booking.ID, event.EntityID)
}
}
func TestPromoteWaitlistWritesSystemEvent(t *testing.T) {
repo := db.NewMemoryRepository()
// Demo session capacity is 4 — three confirmed leaves exactly one seat.
for _, name := range []string{"fill-a", "fill-b", "fill-c"} {
seedSessionBooking(t, repo, name, "confirmed")
}
waiting := seedSessionBooking(t, repo, "queued", "waitlisted")
promoted, err := PromoteWaitlist(context.Background(), repo, activityTenantID, activitySessionID)
if err != nil {
t.Fatalf("promote waitlist: %v", err)
}
if len(promoted) != 1 || promoted[0].ID != waiting.ID {
t.Fatalf("expected queued booking promoted, got %v", promoted)
}
events, err := repo.ListActivityEvents(context.Background(), activityTenantID, 10)
if err != nil {
t.Fatalf("list activity: %v", err)
}
for _, event := range events {
if event.Action == "booking.promoted" && event.ActorKind == "system" {
if event.EntityID == nil || *event.EntityID != waiting.ID {
t.Fatalf("expected entity id %s, got %v", waiting.ID, event.EntityID)
}
return
}
}
t.Fatal("expected system booking.promoted event")
}
@@ -226,6 +226,17 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
}
}
serviceName, _ := s.descriptors(ctx, booking)
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
TenantID: booking.TenantID,
ActorKind: "customer",
ActorName: booking.CustomerName,
Action: "booking.rescheduled",
EntityType: "booking",
EntityID: &booking.ID,
Summary: booking.CustomerName + " — " + serviceName,
})
// Send reschedule email (async)
go s.sendRescheduleEmail(booking, newStartsAt, newEndsAt)
@@ -250,6 +261,16 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t
return err
}
_ = s.repo.CancelPendingReminderJobsForBooking(ctx, booking.ID)
serviceName, _ := s.descriptors(ctx, booking)
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
TenantID: booking.TenantID,
ActorKind: "customer",
ActorName: booking.CustomerName,
Action: "booking.cancelled",
EntityType: "booking",
EntityID: &booking.ID,
Summary: booking.CustomerName + " — " + serviceName,
})
// Send cancellation email (async)
go s.sendCancellationEmail(booking)
@@ -534,6 +555,15 @@ func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference st
updated := booking
updated.ClassSessionID = &target.ID
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
TenantID: booking.TenantID,
ActorKind: "customer",
ActorName: booking.CustomerName,
Action: "booking.moved",
EntityType: "booking",
EntityID: &booking.ID,
Summary: booking.CustomerName + " — " + target.Title,
})
go s.sendRescheduleEmail(updated, target.StartsAt, target.EndsAt)
return domain.MoveBookingResponse{Status: newStatus}, nil
@@ -55,6 +55,14 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
}
confirmed++
promoted = append(promoted, booking)
_ = repo.AppendActivityEvent(ctx, db.ActivityEventParams{
TenantID: tenantID,
ActorKind: "system",
Action: "booking.promoted",
EntityType: "booking",
EntityID: &booking.ID,
Summary: booking.CustomerName + " — " + session.Title,
})
}
return promoted, nil
}
+14
View File
@@ -276,6 +276,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
}
status := "confirmed"
activityLabel := ""
switch request.BookingMode {
case "appointment":
if request.ServiceID == nil || request.ClassSessionID != nil {
@@ -288,6 +289,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
if !ok {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking)
}
activityLabel = service.Name
expectedDuration := time.Duration(service.DurationMinutes) * time.Minute
if !startsAt.Add(expectedDuration).Equal(endsAt) {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment duration must match service duration", ErrInvalidBooking)
@@ -317,6 +319,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
if !sameSecond(session.StartsAt, startsAt) || !sameSecond(session.EndsAt, endsAt) {
return domain.CreateBookingResponse{}, fmt.Errorf("%w: class booking time must match class session", ErrInvalidBooking)
}
activityLabel = session.Title
classBookings := countClassBookings(existing, *request.ClassSessionID)
if session.Capacity > 0 && classBookings >= session.Capacity {
status = "waitlisted"
@@ -366,6 +369,17 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
return domain.CreateBookingResponse{}, err
}
// Audit feed — best-effort like confirmation emails.
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
TenantID: tenant.ID,
ActorKind: "customer",
ActorName: customerName,
Action: "booking.created",
EntityType: "booking",
EntityID: &created.ID,
Summary: customerName + " — " + activityLabel,
})
// Send confirmation emails (async - don't fail booking if email fails)
go s.sendBookingConfirmationEmails(tenant, created, request.ServiceID, request.LocationID, customerName, customerEmail, startsAt, endsAt)