mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +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,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
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/activity"
|
||||||
"bookra/apps/backend/internal/admin"
|
"bookra/apps/backend/internal/admin"
|
||||||
"bookra/apps/backend/internal/auth"
|
"bookra/apps/backend/internal/auth"
|
||||||
"bookra/apps/backend/internal/billing"
|
"bookra/apps/backend/internal/billing"
|
||||||
@@ -57,6 +58,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
billingService := billing.NewService(cfg, repository)
|
billingService := billing.NewService(cfg, repository)
|
||||||
catalogService := catalog.NewService(repository, billingService, notificationService)
|
catalogService := catalog.NewService(repository, billingService, notificationService)
|
||||||
calendarService := calendar.NewService(repository)
|
calendarService := calendar.NewService(repository)
|
||||||
|
activityService := activity.NewService(repository)
|
||||||
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
||||||
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
||||||
smsService := sms.NewService(cfg, repository)
|
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"})
|
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) {
|
protected.GET("/billing/subscription", func(c *gin.Context) {
|
||||||
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
|
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||||
if err != nil {
|
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)
|
// Send reschedule email (async)
|
||||||
go s.sendRescheduleEmail(booking, newStartsAt, newEndsAt)
|
go s.sendRescheduleEmail(booking, newStartsAt, newEndsAt)
|
||||||
|
|
||||||
@@ -250,6 +261,16 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_ = s.repo.CancelPendingReminderJobsForBooking(ctx, booking.ID)
|
_ = 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)
|
// Send cancellation email (async)
|
||||||
go s.sendCancellationEmail(booking)
|
go s.sendCancellationEmail(booking)
|
||||||
@@ -534,6 +555,15 @@ func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference st
|
|||||||
|
|
||||||
updated := booking
|
updated := booking
|
||||||
updated.ClassSessionID = &target.ID
|
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)
|
go s.sendRescheduleEmail(updated, target.StartsAt, target.EndsAt)
|
||||||
|
|
||||||
return domain.MoveBookingResponse{Status: newStatus}, nil
|
return domain.MoveBookingResponse{Status: newStatus}, nil
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
|
|||||||
}
|
}
|
||||||
confirmed++
|
confirmed++
|
||||||
promoted = append(promoted, booking)
|
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
|
return promoted, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
|||||||
}
|
}
|
||||||
|
|
||||||
status := "confirmed"
|
status := "confirmed"
|
||||||
|
activityLabel := ""
|
||||||
switch request.BookingMode {
|
switch request.BookingMode {
|
||||||
case "appointment":
|
case "appointment":
|
||||||
if request.ServiceID == nil || request.ClassSessionID != nil {
|
if request.ServiceID == nil || request.ClassSessionID != nil {
|
||||||
@@ -288,6 +289,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
|||||||
if !ok {
|
if !ok {
|
||||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking)
|
return domain.CreateBookingResponse{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking)
|
||||||
}
|
}
|
||||||
|
activityLabel = service.Name
|
||||||
expectedDuration := time.Duration(service.DurationMinutes) * time.Minute
|
expectedDuration := time.Duration(service.DurationMinutes) * time.Minute
|
||||||
if !startsAt.Add(expectedDuration).Equal(endsAt) {
|
if !startsAt.Add(expectedDuration).Equal(endsAt) {
|
||||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment duration must match service duration", ErrInvalidBooking)
|
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) {
|
if !sameSecond(session.StartsAt, startsAt) || !sameSecond(session.EndsAt, endsAt) {
|
||||||
return domain.CreateBookingResponse{}, fmt.Errorf("%w: class booking time must match class session", ErrInvalidBooking)
|
return domain.CreateBookingResponse{}, fmt.Errorf("%w: class booking time must match class session", ErrInvalidBooking)
|
||||||
}
|
}
|
||||||
|
activityLabel = session.Title
|
||||||
classBookings := countClassBookings(existing, *request.ClassSessionID)
|
classBookings := countClassBookings(existing, *request.ClassSessionID)
|
||||||
if session.Capacity > 0 && classBookings >= session.Capacity {
|
if session.Capacity > 0 && classBookings >= session.Capacity {
|
||||||
status = "waitlisted"
|
status = "waitlisted"
|
||||||
@@ -366,6 +369,17 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
|||||||
return domain.CreateBookingResponse{}, err
|
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)
|
// Send confirmation emails (async - don't fail booking if email fails)
|
||||||
go s.sendBookingConfirmationEmails(tenant, created, request.ServiceID, request.LocationID, customerName, customerEmail, startsAt, endsAt)
|
go s.sendBookingConfirmationEmails(tenant, created, request.ServiceID, request.LocationID, customerName, customerEmail, startsAt, endsAt)
|
||||||
|
|
||||||
|
|||||||
@@ -1365,6 +1365,16 @@ func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_ = s.repo.CancelPendingReminderJobsForBooking(ctx, booking.ID)
|
_ = 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)
|
go s.sendBookingEmail(booking, false)
|
||||||
|
|
||||||
@@ -1395,10 +1405,46 @@ func (s *Service) PromoteBooking(ctx context.Context, principal domain.Principal
|
|||||||
}
|
}
|
||||||
return err
|
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)
|
go s.sendBookingEmail(promoted, true)
|
||||||
return nil
|
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
|
// sendBookingEmail builds the shared payload and fires a confirmation
|
||||||
// (promoted=true) or cancellation notification. Failures are logged-and-done:
|
// (promoted=true) or cancellation notification. Failures are logged-and-done:
|
||||||
// email delivery must not fail the cancellation itself.
|
// email delivery must not fail the cancellation itself.
|
||||||
@@ -1419,17 +1465,8 @@ func (s *Service) sendBookingEmail(booking db.BookingRecord, promoted bool) {
|
|||||||
brandColor = "#a65c3e"
|
brandColor = "#a65c3e"
|
||||||
}
|
}
|
||||||
|
|
||||||
var serviceName, locationName string
|
var locationName string
|
||||||
if booking.ServiceID != nil {
|
serviceName := s.bookingLabel(ctx, booking)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if booking.LocationID != nil {
|
if booking.LocationID != nil {
|
||||||
if location, err := s.repo.GetLocationByID(ctx, *booking.LocationID); err == nil {
|
if location, err := s.repo.GetLocationByID(ctx, *booking.LocationID); err == nil {
|
||||||
locationName = location.Name
|
locationName = location.Name
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -142,6 +142,10 @@ type Repository interface {
|
|||||||
ListStaffFeedTokens(ctx context.Context, tenantID string) ([]StaffFeedTokenRecord, error)
|
ListStaffFeedTokens(ctx context.Context, tenantID string) ([]StaffFeedTokenRecord, error)
|
||||||
RotateTenantFeedToken(ctx context.Context, tenantID string) (string, error)
|
RotateTenantFeedToken(ctx context.Context, tenantID string) (string, error)
|
||||||
RotateStaffFeedToken(ctx context.Context, staffID 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 {
|
type TenantRecord struct {
|
||||||
@@ -298,6 +302,30 @@ type StaffFeedTokenRecord struct {
|
|||||||
FeedToken string
|
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 {
|
type CreateStaffParams struct {
|
||||||
TenantID string
|
TenantID string
|
||||||
LocationID *string
|
LocationID *string
|
||||||
@@ -819,6 +847,7 @@ type MemoryRepository struct {
|
|||||||
emailTemplates []EmailTemplateRecord
|
emailTemplates []EmailTemplateRecord
|
||||||
tenantFeedToken string
|
tenantFeedToken string
|
||||||
staffFeedTokens map[string]string
|
staffFeedTokens map[string]string
|
||||||
|
activityEvents []ActivityEventRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMemoryRepository() *MemoryRepository {
|
func NewMemoryRepository() *MemoryRepository {
|
||||||
@@ -945,6 +974,26 @@ func NewMemoryRepository() *MemoryRepository {
|
|||||||
staffFeedTokens: map[string]string{
|
staffFeedTokens: map[string]string{
|
||||||
staffID: "demo-staff-feed-token",
|
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
|
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) {
|
func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
|
||||||
if tenantID != r.tenant.ID {
|
if tenantID != r.tenant.ID {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -539,6 +539,23 @@ type CancelBookingRequest struct {
|
|||||||
Reason string `json:"reason,omitempty"`
|
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
|
// ADMIN MODELS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Per-tenant activity feed: who changed what. Actor kinds distinguish
|
||||||
|
-- dashboard users (staff), public self-service actions (customer), and
|
||||||
|
-- automatic transitions (system). Kept to booking lifecycle events in v1.
|
||||||
|
CREATE TABLE activity_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
actor_kind text NOT NULL DEFAULT 'staff',
|
||||||
|
actor_subject text NOT NULL DEFAULT '',
|
||||||
|
actor_name text NOT NULL DEFAULT '',
|
||||||
|
action text NOT NULL,
|
||||||
|
entity_type text NOT NULL DEFAULT 'booking',
|
||||||
|
entity_id uuid,
|
||||||
|
summary text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX activity_events_tenant_created_idx
|
||||||
|
ON activity_events (tenant_id, created_at DESC);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS activity_events;
|
||||||
@@ -1404,6 +1404,33 @@ paths:
|
|||||||
description: Invalid scope or foreign staff member
|
description: Invalid scope or foreign staff member
|
||||||
"404":
|
"404":
|
||||||
description: Tenant membership not found
|
description: Tenant membership not found
|
||||||
|
/v1/activity:
|
||||||
|
get:
|
||||||
|
tags: [Activity]
|
||||||
|
operationId: listActivityEvents
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
summary: Recent tenant activity feed
|
||||||
|
description: >-
|
||||||
|
Audit feed of booking lifecycle events — created, cancelled,
|
||||||
|
rescheduled, moved, promoted — with the actor (staff member, customer,
|
||||||
|
or system) for each change. Newest first.
|
||||||
|
parameters:
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
maximum: 100
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Activity events, newest first
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ActivityFeedResponse"
|
||||||
|
"404":
|
||||||
|
description: Tenant membership not found
|
||||||
/v1/public/calendar/{token}:
|
/v1/public/calendar/{token}:
|
||||||
get:
|
get:
|
||||||
tags: [Calendar]
|
tags: [Calendar]
|
||||||
@@ -2048,6 +2075,41 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/StaffCalendarFeedEntry"
|
$ref: "#/components/schemas/StaffCalendarFeedEntry"
|
||||||
|
ActivityEvent:
|
||||||
|
type: object
|
||||||
|
required: [id, actorKind, action, entityType, summary, createdAt]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
actorKind:
|
||||||
|
type: string
|
||||||
|
enum: [staff, customer, system]
|
||||||
|
description: Who performed the change — dashboard user, self-service customer, or an automatic transition
|
||||||
|
actorName:
|
||||||
|
type: string
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
description: Dotted verb, e.g. booking.created, booking.cancelled, booking.promoted
|
||||||
|
entityType:
|
||||||
|
type: string
|
||||||
|
entityId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
|
description: Human-readable label, e.g. "Alice Johnson — Relaxation massage"
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
ActivityFeedResponse:
|
||||||
|
type: object
|
||||||
|
required: [events]
|
||||||
|
properties:
|
||||||
|
events:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ActivityEvent"
|
||||||
DashboardBookingRequest:
|
DashboardBookingRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [customerName, customerEmail, startsAt]
|
required: [customerName, customerEmail, startsAt]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSignal, createMemo, Show } from "solid-js";
|
import { createSignal, createMemo, createResource, Show, Accessor } from "solid-js";
|
||||||
import { useI18n } from "../../providers/i18n-provider";
|
import { useI18n } from "../../providers/i18n-provider";
|
||||||
|
import { apiClient } from "../../lib/api-client";
|
||||||
import { BellIcon } from "./icons";
|
import { BellIcon } from "./icons";
|
||||||
import { AnimatedList } from "../animated-list";
|
import { AnimatedList } from "../animated-list";
|
||||||
|
|
||||||
@@ -12,25 +13,69 @@ interface NotificationItem {
|
|||||||
read: boolean;
|
read: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationDropdown(props: { bookings?: any[]; billing?: any }) {
|
interface ActivityEvent {
|
||||||
|
id: string;
|
||||||
|
actorKind: "staff" | "customer" | "system";
|
||||||
|
actorName?: string;
|
||||||
|
action: string;
|
||||||
|
summary: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationDropdown(props: { bookings?: any[]; billing?: any; token?: Accessor<string | null | undefined> }) {
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const [open, setOpen] = createSignal(false);
|
const [open, setOpen] = createSignal(false);
|
||||||
|
|
||||||
|
const [activity] = createResource(
|
||||||
|
() => props.token?.(),
|
||||||
|
async (bearer) => {
|
||||||
|
if (!bearer) return [] as ActivityEvent[];
|
||||||
|
const response = await (apiClient as any).GET("/v1/activity", {
|
||||||
|
params: { query: { limit: 20 } },
|
||||||
|
headers: { Authorization: `Bearer ${bearer}` },
|
||||||
|
});
|
||||||
|
return (response.data?.events ?? []) as ActivityEvent[];
|
||||||
|
},
|
||||||
|
{ initialValue: [] },
|
||||||
|
);
|
||||||
|
|
||||||
const notifications = createMemo<NotificationItem[]>(() => {
|
const notifications = createMemo<NotificationItem[]>(() => {
|
||||||
const items: NotificationItem[] = [];
|
const items: NotificationItem[] = [];
|
||||||
const cs = i18n.locale() === "cs";
|
const cs = i18n.locale() === "cs";
|
||||||
const bookings = props.bookings ?? [];
|
const locale = cs ? "cs-CZ" : "en-US";
|
||||||
|
const events = activity() ?? [];
|
||||||
|
|
||||||
// Recent bookings as notifications
|
if (events.length > 0) {
|
||||||
bookings.slice(0, 3).forEach((b: any, i: number) => {
|
// Real audit feed — replaces the derived "recent bookings" guesses.
|
||||||
items.push({
|
const actionLabel = (action: string) => {
|
||||||
id: `booking-${i}`,
|
const key = `dashboard.activity.${action}`;
|
||||||
type: "booking",
|
const translated = i18n.t(key);
|
||||||
title: `${cs ? "Nová rezervace" : "New booking"} ${b.customerName}`,
|
return translated === key ? action : translated;
|
||||||
message: `${b.service} — ${new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US")}`,
|
};
|
||||||
time: new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US", { month: "short", day: "numeric" }),
|
events.forEach((event) => {
|
||||||
read: false,
|
const actorSuffix = event.actorKind === "staff" && event.actorName ? ` · ${event.actorName}` : "";
|
||||||
|
items.push({
|
||||||
|
id: `activity-${event.id}`,
|
||||||
|
type: "booking",
|
||||||
|
title: actionLabel(event.action),
|
||||||
|
message: `${event.summary}${actorSuffix}`,
|
||||||
|
time: new Date(event.createdAt).toLocaleString(locale, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }),
|
||||||
|
read: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
} else {
|
||||||
|
const bookings = props.bookings ?? [];
|
||||||
|
bookings.slice(0, 3).forEach((b: any, i: number) => {
|
||||||
|
items.push({
|
||||||
|
id: `booking-${i}`,
|
||||||
|
type: "booking",
|
||||||
|
title: `${cs ? "Nová rezervace" : "New booking"} ${b.customerName}`,
|
||||||
|
message: `${b.service} — ${new Date(b.startsAt).toLocaleDateString(locale)}`,
|
||||||
|
time: new Date(b.startsAt).toLocaleDateString(locale, { month: "short", day: "numeric" }),
|
||||||
|
read: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Plan/trial notifications
|
// Plan/trial notifications
|
||||||
const billing = props.billing;
|
const billing = props.billing;
|
||||||
|
|||||||
@@ -362,6 +362,11 @@ const dictionaries = {
|
|||||||
"dashboard.noNotifications": "Žádná nová oznámení",
|
"dashboard.noNotifications": "Žádná nová oznámení",
|
||||||
"dashboard.markAllRead": "Označit vše jako přečtené",
|
"dashboard.markAllRead": "Označit vše jako přečtené",
|
||||||
"dashboard.notifications.title": "Oznámení",
|
"dashboard.notifications.title": "Oznámení",
|
||||||
|
"dashboard.activity.booking.created": "Nová rezervace",
|
||||||
|
"dashboard.activity.booking.cancelled": "Rezervace zrušena",
|
||||||
|
"dashboard.activity.booking.rescheduled": "Rezervace přesunuta",
|
||||||
|
"dashboard.activity.booking.moved": "Přesun na jinou lekci",
|
||||||
|
"dashboard.activity.booking.promoted": "Povýšení z čekací listiny",
|
||||||
"dashboard.demoBanner": "Demo režim",
|
"dashboard.demoBanner": "Demo režim",
|
||||||
"dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.",
|
"dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.",
|
||||||
"dashboard.demoBannerCTA": "Vytvořit účet",
|
"dashboard.demoBannerCTA": "Vytvořit účet",
|
||||||
@@ -957,6 +962,11 @@ const dictionaries = {
|
|||||||
"dashboard.noNotifications": "No new notifications",
|
"dashboard.noNotifications": "No new notifications",
|
||||||
"dashboard.markAllRead": "Mark all as read",
|
"dashboard.markAllRead": "Mark all as read",
|
||||||
"dashboard.notifications.title": "Notifications",
|
"dashboard.notifications.title": "Notifications",
|
||||||
|
"dashboard.activity.booking.created": "New booking",
|
||||||
|
"dashboard.activity.booking.cancelled": "Booking cancelled",
|
||||||
|
"dashboard.activity.booking.rescheduled": "Booking rescheduled",
|
||||||
|
"dashboard.activity.booking.moved": "Moved to another session",
|
||||||
|
"dashboard.activity.booking.promoted": "Promoted from waitlist",
|
||||||
"dashboard.demoBanner": "Demo mode",
|
"dashboard.demoBanner": "Demo mode",
|
||||||
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
|
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
|
||||||
"dashboard.demoBannerCTA": "Create account",
|
"dashboard.demoBannerCTA": "Create account",
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ function DashboardShell(props: { children: JSX.Element }) {
|
|||||||
<div class="lg:hidden bg-canvas/80 backdrop-blur-xl border-b border-border p-4 flex items-center justify-between sticky top-0 z-40">
|
<div class="lg:hidden bg-canvas/80 backdrop-blur-xl border-b border-border p-4 flex items-center justify-between sticky top-0 z-40">
|
||||||
<DashboardLogo />
|
<DashboardLogo />
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} />
|
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} token={data.token} />
|
||||||
<button onClick={() => setIsMobileMenuOpen(true)} aria-label={data.i18n.locale() === "cs" ? "Otevřít menu" : "Open menu"} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all">
|
<button onClick={() => setIsMobileMenuOpen(true)} aria-label={data.i18n.locale() === "cs" ? "Otevřít menu" : "Open menu"} class="p-2 hover:bg-canvas-subtle rounded-xl transition-all">
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function OverviewPage() {
|
|||||||
{data.i18n.t("dashboard.shareManage")}
|
{data.i18n.t("dashboard.shareManage")}
|
||||||
</button>
|
</button>
|
||||||
<span class="text-sm text-ink-subtle hidden sm:inline">{new Date().toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "long", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
<span class="text-sm text-ink-subtle hidden sm:inline">{new Date().toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "long", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
||||||
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} />
|
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} token={data.token} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/activity": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Recent tenant activity feed
|
||||||
|
* @description Audit feed of booking lifecycle events — created, cancelled, rescheduled, moved, promoted — with the actor (staff member, customer, or system) for each change. Newest first.
|
||||||
|
*/
|
||||||
|
get: operations["listActivityEvents"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/admin/stats": {
|
"/v1/admin/stats": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1060,6 +1080,28 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
ActivityEvent: {
|
||||||
|
/** @description Dotted verb, e.g. booking.created, booking.cancelled, booking.promoted */
|
||||||
|
action: string;
|
||||||
|
/**
|
||||||
|
* @description Who performed the change — dashboard user, self-service customer, or an automatic transition
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
actorKind: "staff" | "customer" | "system";
|
||||||
|
actorName?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
entityId?: string;
|
||||||
|
entityType: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** @description Human-readable label, e.g. "Alice Johnson — Relaxation massage" */
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
ActivityFeedResponse: {
|
||||||
|
events: components["schemas"]["ActivityEvent"][];
|
||||||
|
};
|
||||||
AuthResponse: {
|
AuthResponse: {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
expiresIn?: number;
|
expiresIn?: number;
|
||||||
@@ -1679,6 +1721,35 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listActivityEvents: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Activity events, newest first */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ActivityFeedResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Tenant membership not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getAdminStats: {
|
getAdminStats: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+10
-2
@@ -162,6 +162,15 @@
|
|||||||
- `SessionMoveTarget` schema + both endpoints documented in OpenAPI; generated client regenerated
|
- `SessionMoveTarget` schema + both endpoints documented in OpenAPI; generated client regenerated
|
||||||
- verified live + browser: created a second session of the demo template, moved a confirmed booking over HTTP (200 + new `classSessionId`), then repeated through the real manage page in Czech and English — success banner, updated date/time, clean console
|
- verified live + browser: created a second session of the demo template, moved a confirmed booking over HTTP (200 + new `classSessionId`), then repeated through the real manage page in Czech and English — success banner, updated date/time, clean console
|
||||||
- remaining: live staging validation on Neon + pgx repository path
|
- remaining: live staging validation on Neon + pgx repository path
|
||||||
|
- `[done/partial]` Milestone 24: Per-tenant activity feed
|
||||||
|
- `activity_events` table (migration 00016) + `GET /v1/activity` — tenant-scoped audit feed, newest first, `limit` capped at 100; `ActivityEvent`/`ActivityFeedResponse` in OpenAPI
|
||||||
|
- actor model distinguishes `staff` (dashboard user, principal name/subject), `customer` (self-service via manage token), and `system` (automatic waitlist promotion)
|
||||||
|
- booking lifecycle writes land at every transition: public create → `booking.created`, dashboard cancel + manual promote → staff events, customer cancel/reschedule/move → customer events, `PromoteWaitlist` auto-drain → system event; all writes are best-effort (`_ =`) so the feed can never fail a booking
|
||||||
|
- summaries are human-readable `{customer} — {service/session title}` resolved at write time; the frontend never needs secondary lookups
|
||||||
|
- notification dropdown now leads with the real feed when it returns events (derived recent-bookings guesses remain as fallback); unknown future actions degrade to the raw action string instead of a broken i18n key; CS/EN labels for all five actions
|
||||||
|
- demo repo seeds three varied events so the dropdown has content on first open
|
||||||
|
- verified live: feed returns seeds newest-first, public booking create lands `booking.created` with entity id, customer cancel lands `booking.cancelled`; browser-checked both locales against the demo dashboard, console clean
|
||||||
|
- remaining: live staging validation on Neon + pgx repository path; settings/CRUD edits can join the feed when a concrete need appears
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
|
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
|
||||||
@@ -411,11 +420,10 @@ Key entities:
|
|||||||
## Future Phases
|
## Future Phases
|
||||||
Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle:
|
Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle:
|
||||||
|
|
||||||
- **Activity/audit log**: a per-tenant feed of who changed what (booking created/cancelled/promoted, settings edits). Natural fit for the notification dropdown and useful for multi-staff accountability.
|
|
||||||
- **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request.
|
- **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request.
|
||||||
- **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios.
|
- **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios.
|
||||||
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
|
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
|
||||||
- **Persistent notification center**: the dropdown currently derives notifications from summary data; a `notifications` table would enable read/unread state and delivery history in-app.
|
- **Persistent notification center**: M24 put real activity events in the dropdown, but read state is still in-memory and there's no delivery history; a `notifications` table (or read-cursor on `activity_events`) would persist both.
|
||||||
- **Staff performance view**: bookings/completion/utilization per staff member — the metrics primitives already exist; this is a filtered presentation layer.
|
- **Staff performance view**: bookings/completion/utilization per staff member — the metrics primitives already exist; this is a filtered presentation layer.
|
||||||
- **CSV export**: bookings and customers export for accounting/backup; cheap to build, frequently requested by small businesses.
|
- **CSV export**: bookings and customers export for accounting/backup; cheap to build, frequently requested by small businesses.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user