diff --git a/apps/backend/internal/activity/service.go b/apps/backend/internal/activity/service.go new file mode 100644 index 0000000..18b96ba --- /dev/null +++ b/apps/backend/internal/activity/service.go @@ -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 +} diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 123dec1..b0046fe 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -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 { diff --git a/apps/backend/internal/bookings/activity_test.go b/apps/backend/internal/bookings/activity_test.go new file mode 100644 index 0000000..acd7363 --- /dev/null +++ b/apps/backend/internal/bookings/activity_test.go @@ -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") +} diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index cc033c3..2393b35 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -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 diff --git a/apps/backend/internal/bookings/lifecycle.go b/apps/backend/internal/bookings/lifecycle.go index 1f244cb..20f12bb 100644 --- a/apps/backend/internal/bookings/lifecycle.go +++ b/apps/backend/internal/bookings/lifecycle.go @@ -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 } diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 61d2dae..a347e65 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -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) diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index df3f2b7..533c12f 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -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 diff --git a/apps/backend/internal/db/activity.go b/apps/backend/internal/db/activity.go new file mode 100644 index 0000000..0928c93 --- /dev/null +++ b/apps/backend/internal/db/activity.go @@ -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() +} diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index c19b340..f2687a3 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -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 diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 398c7a9..7aa0ab7 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -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 // ============================================ diff --git a/apps/backend/migrations/00016_activity_events.sql b/apps/backend/migrations/00016_activity_events.sql new file mode 100644 index 0000000..b0d6d32 --- /dev/null +++ b/apps/backend/migrations/00016_activity_events.sql @@ -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; diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index fb79b0a..f9c7fd9 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -1404,6 +1404,33 @@ paths: description: Invalid scope or foreign staff member "404": 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}: get: tags: [Calendar] @@ -2048,6 +2075,41 @@ components: type: array items: $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: type: object required: [customerName, customerEmail, startsAt] diff --git a/apps/frontend/src/components/dashboard/notification-dropdown.tsx b/apps/frontend/src/components/dashboard/notification-dropdown.tsx index 03fca7c..1971fd9 100644 --- a/apps/frontend/src/components/dashboard/notification-dropdown.tsx +++ b/apps/frontend/src/components/dashboard/notification-dropdown.tsx @@ -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 { apiClient } from "../../lib/api-client"; import { BellIcon } from "./icons"; import { AnimatedList } from "../animated-list"; @@ -12,25 +13,69 @@ interface NotificationItem { 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 }) { const i18n = useI18n(); 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(() => { const items: NotificationItem[] = []; const cs = i18n.locale() === "cs"; - const bookings = props.bookings ?? []; + const locale = cs ? "cs-CZ" : "en-US"; + const events = activity() ?? []; - // Recent bookings as notifications - 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(cs ? "cs-CZ" : "en-US")}`, - time: new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US", { month: "short", day: "numeric" }), - read: false, + if (events.length > 0) { + // Real audit feed — replaces the derived "recent bookings" guesses. + const actionLabel = (action: string) => { + const key = `dashboard.activity.${action}`; + const translated = i18n.t(key); + return translated === key ? action : translated; + }; + events.forEach((event) => { + 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 const billing = props.billing; diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 7431a4b..8b6ba2c 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -362,6 +362,11 @@ const dictionaries = { "dashboard.noNotifications": "Žádná nová oznámení", "dashboard.markAllRead": "Označit vše jako přečtené", "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.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.", "dashboard.demoBannerCTA": "Vytvořit účet", @@ -957,6 +962,11 @@ const dictionaries = { "dashboard.noNotifications": "No new notifications", "dashboard.markAllRead": "Mark all as read", "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.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.", "dashboard.demoBannerCTA": "Create account", diff --git a/apps/frontend/src/routes/dashboard/layout.tsx b/apps/frontend/src/routes/dashboard/layout.tsx index 2538886..1e9aff9 100644 --- a/apps/frontend/src/routes/dashboard/layout.tsx +++ b/apps/frontend/src/routes/dashboard/layout.tsx @@ -379,7 +379,7 @@ function DashboardShell(props: { children: JSX.Element }) {
- + diff --git a/apps/frontend/src/routes/dashboard/overview-page.tsx b/apps/frontend/src/routes/dashboard/overview-page.tsx index dad8717..b6932a5 100644 --- a/apps/frontend/src/routes/dashboard/overview-page.tsx +++ b/apps/frontend/src/routes/dashboard/overview-page.tsx @@ -32,7 +32,7 @@ function OverviewPage() { {data.i18n.t("dashboard.shareManage")} - +
diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 3001853..6ce9ed5 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -15,6 +15,26 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -1060,6 +1080,28 @@ export interface paths { export type webhooks = Record; export interface components { 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: { accessToken: string; 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: { parameters: { query?: never; diff --git a/project.md b/project.md index c580a27..b420e1a 100644 --- a/project.md +++ b/project.md @@ -162,6 +162,15 @@ - `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 - 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 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 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. - **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. -- **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. - **CSV export**: bookings and customers export for accounting/backup; cheap to build, frequently requested by small businesses.