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

+55
View File
@@ -0,0 +1,55 @@
package activity
import (
"context"
"errors"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
var ErrTenantMembership = errors.New("tenant membership not found")
const defaultLimit = 50
const maxLimit = 100
type Service struct {
repo db.Repository
}
func NewService(repo db.Repository) *Service {
return &Service{repo: repo}
}
// List returns the tenant's recent activity, newest first. The feed is
// tenant-scoped — every dashboard user sees the same audit trail.
func (s *Service) List(ctx context.Context, principal domain.Principal, limit int) (domain.ActivityFeedResponse, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.ActivityFeedResponse{}, ErrTenantMembership
}
if limit <= 0 || limit > maxLimit {
limit = defaultLimit
}
records, err := s.repo.ListActivityEvents(ctx, membership.Tenant.ID, limit)
if err != nil {
return domain.ActivityFeedResponse{}, err
}
events := make([]domain.ActivityEvent, 0, len(records))
for _, rec := range records {
event := domain.ActivityEvent{
ID: rec.ID,
ActorKind: rec.ActorKind,
ActorName: rec.ActorName,
Action: rec.Action,
EntityType: rec.EntityType,
Summary: rec.Summary,
CreatedAt: rec.CreatedAt,
}
if rec.EntityID != nil {
event.EntityID = *rec.EntityID
}
events = append(events, event)
}
return domain.ActivityFeedResponse{Events: events}, nil
}
+16
View File
@@ -11,6 +11,7 @@ import (
"strings"
"time"
"bookra/apps/backend/internal/activity"
"bookra/apps/backend/internal/admin"
"bookra/apps/backend/internal/auth"
"bookra/apps/backend/internal/billing"
@@ -57,6 +58,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
billingService := billing.NewService(cfg, repository)
catalogService := catalog.NewService(repository, billingService, notificationService)
calendarService := calendar.NewService(repository)
activityService := activity.NewService(repository)
authService := auth.NewService(repository, cfg.AuthJWTSecret)
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
smsService := sms.NewService(cfg, repository)
@@ -1277,6 +1279,20 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, gin.H{"token": token, "feedPath": "/v1/public/calendar/" + token + ".ics"})
})
protected.GET("/activity", func(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
response, err := activityService.List(c.Request.Context(), auth.PrincipalFromContext(c), limit)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, activity.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.GET("/billing/subscription", func(c *gin.Context) {
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
@@ -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)
+48 -11
View File
@@ -1365,6 +1365,16 @@ func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal,
return err
}
_ = s.repo.CancelPendingReminderJobsForBooking(ctx, booking.ID)
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
TenantID: membership.Tenant.ID,
ActorKind: "staff",
ActorSubject: principal.Subject,
ActorName: actorDisplayName(principal),
Action: "booking.cancelled",
EntityType: "booking",
EntityID: &booking.ID,
Summary: booking.CustomerName + " — " + s.bookingLabel(ctx, booking),
})
go s.sendBookingEmail(booking, false)
@@ -1395,10 +1405,46 @@ func (s *Service) PromoteBooking(ctx context.Context, principal domain.Principal
}
return err
}
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
TenantID: membership.Tenant.ID,
ActorKind: "staff",
ActorSubject: principal.Subject,
ActorName: actorDisplayName(principal),
Action: "booking.promoted",
EntityType: "booking",
EntityID: &promoted.ID,
Summary: promoted.CustomerName + " — " + s.bookingLabel(ctx, promoted),
})
go s.sendBookingEmail(promoted, true)
return nil
}
// bookingLabel resolves the display label for a booking — service name for
// appointments, session title for classes.
func (s *Service) bookingLabel(ctx context.Context, booking db.BookingRecord) string {
if booking.ServiceID != nil {
if service, err := s.repo.GetServiceByID(ctx, *booking.ServiceID); err == nil {
return service.Name
}
}
if booking.ClassSessionID != nil {
if session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID); err == nil {
return session.Title
}
}
return ""
}
func actorDisplayName(principal domain.Principal) string {
if principal.Name != "" {
return principal.Name
}
if principal.Email != "" {
return principal.Email
}
return principal.Subject
}
// sendBookingEmail builds the shared payload and fires a confirmation
// (promoted=true) or cancellation notification. Failures are logged-and-done:
// email delivery must not fail the cancellation itself.
@@ -1419,17 +1465,8 @@ func (s *Service) sendBookingEmail(booking db.BookingRecord, promoted bool) {
brandColor = "#a65c3e"
}
var serviceName, locationName string
if booking.ServiceID != nil {
if service, err := s.repo.GetServiceByID(ctx, *booking.ServiceID); err == nil {
serviceName = service.Name
}
}
if booking.ClassSessionID != nil {
if session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID); err == nil {
serviceName = session.Title
}
}
var locationName string
serviceName := s.bookingLabel(ctx, booking)
if booking.LocationID != nil {
if location, err := s.repo.GetLocationByID(ctx, *booking.LocationID); err == nil {
locationName = location.Name
+42
View File
@@ -0,0 +1,42 @@
package db
import (
"context"
)
func (r *PGRepository) AppendActivityEvent(ctx context.Context, params ActivityEventParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO activity_events
(tenant_id, actor_kind, actor_subject, actor_name, action, entity_type, entity_id, summary)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, params.TenantID, params.ActorKind, params.ActorSubject, params.ActorName,
params.Action, params.EntityType, params.EntityID, params.Summary)
return err
}
func (r *PGRepository) ListActivityEvents(ctx context.Context, tenantID string, limit int) ([]ActivityEventRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, actor_kind, actor_name, action, entity_type, entity_id, summary, created_at
FROM activity_events
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2
`, tenantID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var events []ActivityEventRecord
for rows.Next() {
var rec ActivityEventRecord
if err := rows.Scan(
&rec.ID, &rec.TenantID, &rec.ActorKind, &rec.ActorName,
&rec.Action, &rec.EntityType, &rec.EntityID, &rec.Summary, &rec.CreatedAt,
); err != nil {
return nil, err
}
events = append(events, rec)
}
return events, rows.Err()
}
+78
View File
@@ -142,6 +142,10 @@ type Repository interface {
ListStaffFeedTokens(ctx context.Context, tenantID string) ([]StaffFeedTokenRecord, error)
RotateTenantFeedToken(ctx context.Context, tenantID string) (string, error)
RotateStaffFeedToken(ctx context.Context, staffID string) (string, error)
// Activity feed
AppendActivityEvent(ctx context.Context, params ActivityEventParams) error
ListActivityEvents(ctx context.Context, tenantID string, limit int) ([]ActivityEventRecord, error)
}
type TenantRecord struct {
@@ -298,6 +302,30 @@ type StaffFeedTokenRecord struct {
FeedToken string
}
// ActivityEventRecord / ActivityEventParams back the per-tenant audit feed.
type ActivityEventRecord struct {
ID string
TenantID string
ActorKind string
ActorName string
Action string
EntityType string
EntityID *string
Summary string
CreatedAt time.Time
}
type ActivityEventParams struct {
TenantID string
ActorKind string
ActorSubject string
ActorName string
Action string
EntityType string
EntityID *string
Summary string
}
type CreateStaffParams struct {
TenantID string
LocationID *string
@@ -819,6 +847,7 @@ type MemoryRepository struct {
emailTemplates []EmailTemplateRecord
tenantFeedToken string
staffFeedTokens map[string]string
activityEvents []ActivityEventRecord
}
func NewMemoryRepository() *MemoryRepository {
@@ -945,6 +974,26 @@ func NewMemoryRepository() *MemoryRepository {
staffFeedTokens: map[string]string{
staffID: "demo-staff-feed-token",
},
activityEvents: []ActivityEventRecord{
{
ID: uuid.NewString(), TenantID: tenantID, ActorKind: "staff", ActorName: "Demo Stylist",
Action: "booking.cancelled", EntityType: "booking",
Summary: "Carol White — Consultation",
CreatedAt: now.Add(-50 * time.Hour),
},
{
ID: uuid.NewString(), TenantID: tenantID, ActorKind: "system",
Action: "booking.promoted", EntityType: "booking",
Summary: "Bob Smith — Small group mobility class",
CreatedAt: now.Add(-26 * time.Hour),
},
{
ID: uuid.NewString(), TenantID: tenantID, ActorKind: "customer", ActorName: "Alice Johnson",
Action: "booking.created", EntityType: "booking",
Summary: "Alice Johnson — Relaxation massage",
CreatedAt: now.Add(-3 * time.Hour),
},
},
}
}
@@ -1315,6 +1364,35 @@ func (r *MemoryRepository) RotateStaffFeedToken(_ context.Context, staffID strin
return "", pgx.ErrNoRows
}
func (r *MemoryRepository) AppendActivityEvent(_ context.Context, params ActivityEventParams) error {
if params.TenantID != r.tenant.ID {
return nil
}
r.activityEvents = append(r.activityEvents, ActivityEventRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
ActorKind: params.ActorKind,
ActorName: params.ActorName,
Action: params.Action,
EntityType: params.EntityType,
EntityID: params.EntityID,
Summary: params.Summary,
CreatedAt: time.Now().UTC(),
})
return nil
}
func (r *MemoryRepository) ListActivityEvents(_ context.Context, tenantID string, limit int) ([]ActivityEventRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
var events []ActivityEventRecord
for i := len(r.activityEvents) - 1; i >= 0 && len(events) < limit; i-- {
events = append(events, r.activityEvents[i])
}
return events, nil
}
func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
+17
View File
@@ -539,6 +539,23 @@ type CancelBookingRequest struct {
Reason string `json:"reason,omitempty"`
}
// ActivityEvent is a per-tenant audit entry: who changed what. ActorKind is
// staff | customer | system; Action is a dotted verb like booking.created.
type ActivityEvent struct {
ID string `json:"id"`
ActorKind string `json:"actorKind"`
ActorName string `json:"actorName,omitempty"`
Action string `json:"action"`
EntityType string `json:"entityType"`
EntityID string `json:"entityId,omitempty"`
Summary string `json:"summary"`
CreatedAt time.Time `json:"createdAt"`
}
type ActivityFeedResponse struct {
Events []ActivityEvent `json:"events"`
}
// ============================================
// ADMIN MODELS
// ============================================