mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 23:51:17 +00:00
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:
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user