From 09f780e7a680e85cfcc43bbd39e635f52efa60aa Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 08:52:25 +0200 Subject: [PATCH 01/13] feat(catalog): add service and staff management end-to-end Services and staff existed as schema + booking FK targets but had no management surface. Adds full CRUD: repository (pgx + in-memory with matching FK semantics), catalog service validation with tenant isolation, MaxStaff entitlement enforcement, protected API routes, OpenAPI schemas, and dashboard screens for both entities. Dashboard summary now resolves staff names on bookings and computes setup completion from real configured state. Booking modal selects from the tenant's actual services; blocked days can be scoped per staff member. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/internal/api/server.go | 138 +++++++ apps/backend/internal/bookings/service.go | 42 +- apps/backend/internal/catalog/service.go | 307 +++++++++++++++ apps/backend/internal/catalog/service_test.go | 261 +++++++++++++ apps/backend/internal/db/repository.go | 217 +++++++++++ apps/backend/internal/db/services.go | 72 +++- apps/backend/internal/db/staff.go | 79 ++++ apps/backend/internal/domain/models.go | 56 +++ apps/backend/openapi/bookra.openapi.yaml | 239 ++++++++++++ .../src/components/dashboard/icons.tsx | 6 + apps/frontend/src/main.tsx | 4 + apps/frontend/src/providers/i18n-provider.tsx | 70 ++++ .../src/routes/dashboard/bookings-page.tsx | 35 +- .../src/routes/dashboard/dashboard-data.ts | 2 +- apps/frontend/src/routes/dashboard/layout.tsx | 4 +- .../src/routes/dashboard/services-page.tsx | 204 ++++++++++ .../src/routes/dashboard/staff-page.tsx | 264 +++++++++++++ .../src/routes/dashboard/zones-page.tsx | 50 ++- apps/frontend/tsconfig.tsbuildinfo | 2 +- packages/api-client/src/generated/types.ts | 368 ++++++++++++++++++ project.md | 17 +- 21 files changed, 2422 insertions(+), 15 deletions(-) create mode 100644 apps/backend/internal/catalog/service_test.go create mode 100644 apps/backend/internal/db/staff.go create mode 100644 apps/frontend/src/routes/dashboard/services-page.tsx create mode 100644 apps/frontend/src/routes/dashboard/staff-page.tsx diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 9a4fbc4..c59bec0 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -541,6 +541,144 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { c.JSON(http.StatusOK, gin.H{"status": "deleted"}) }) + // ============================================ + // CATALOG API - Services + // ============================================ + protected.GET("/catalog/services", func(c *gin.Context) { + response, err := catalogService.ListServices(c.Request.Context(), auth.PrincipalFromContext(c)) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, catalog.ErrTenantMembership) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) + protected.POST("/catalog/services", func(c *gin.Context) { + var request domain.CreateServiceRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + response, err := catalogService.CreateService(c.Request.Context(), auth.PrincipalFromContext(c), request) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership): + status = http.StatusNotFound + case errors.Is(err, catalog.ErrInvalidInput): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, response) + }) + protected.PUT("/catalog/services/:serviceID", func(c *gin.Context) { + var request domain.UpdateServiceRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + response, err := catalogService.UpdateService(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("serviceID"), request) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrServiceNotFound): + status = http.StatusNotFound + case errors.Is(err, catalog.ErrInvalidInput): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) + protected.DELETE("/catalog/services/:serviceID", func(c *gin.Context) { + err := catalogService.DeleteService(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("serviceID")) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, catalog.ErrTenantMembership) || errors.Is(err, catalog.ErrServiceNotFound) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) + }) + + // ============================================ + // CATALOG API - Staff + // ============================================ + protected.GET("/catalog/staff", func(c *gin.Context) { + response, err := catalogService.ListStaff(c.Request.Context(), auth.PrincipalFromContext(c)) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, catalog.ErrTenantMembership) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) + protected.POST("/catalog/staff", func(c *gin.Context) { + var request domain.CreateStaffRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + response, err := catalogService.CreateStaff(c.Request.Context(), auth.PrincipalFromContext(c), request) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership): + status = http.StatusNotFound + case errors.Is(err, catalog.ErrInvalidInput): + status = http.StatusBadRequest + case errors.Is(err, catalog.ErrPlanLimitReached): + status = http.StatusForbidden + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, response) + }) + protected.PUT("/catalog/staff/:staffID", func(c *gin.Context) { + var request domain.UpdateStaffRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + response, err := catalogService.UpdateStaff(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"), request) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrStaffNotFound): + status = http.StatusNotFound + case errors.Is(err, catalog.ErrInvalidInput): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) + protected.DELETE("/catalog/staff/:staffID", func(c *gin.Context) { + err := catalogService.DeleteStaff(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID")) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, catalog.ErrTenantMembership) || errors.Is(err, catalog.ErrStaffNotFound) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) + }) + protected.POST("/catalog/bookings", func(c *gin.Context) { var request domain.DashboardBookingRequest if err := c.ShouldBindJSON(&request); err != nil { diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 78b9555..d20b55c 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -355,6 +355,12 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip locationNames[location.ID] = location.Name } } + staffNames := map[string]string{} + if staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID); err == nil { + for _, member := range staff { + staffNames[member.ID] = member.DisplayName + } + } toDomain := func(booking db.BookingRecord) domain.UpcomingBooking { out := domain.UpcomingBooking{ ID: booking.ID, @@ -372,6 +378,9 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip if booking.LocationID != nil { out.Location = locationNames[*booking.LocationID] } + if booking.StaffID != nil { + out.StaffName = staffNames[*booking.StaffID] + } return out } @@ -408,6 +417,15 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip allBookings = append(allBookings, toDomain(booking)) } + brandName := membership.Tenant.Name + if brand, err := s.repo.GetBrandProfile(ctx, membership.Tenant.ID); err == nil && strings.TrimSpace(brand.Name) != "" { + brandName = brand.Name + } + scheduleCount := 0 + if rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, membership.Tenant.ID); err == nil { + scheduleCount = len(rules) + } + return domain.DashboardSummary{ TenantName: membership.Tenant.Name, TenantSlug: membership.Tenant.Slug, @@ -415,7 +433,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip Timezone: membership.Tenant.Timezone, PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode), PublicBookingURL: "/book/" + membership.Tenant.Slug, - SetupCompletion: 100, + SetupCompletion: setupCompletion(brandName, len(locationNames), len(serviceNames), len(staffNames), scheduleCount), KPIs: []domain.DashboardKPI{ {Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)}, {Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)}, @@ -428,6 +446,28 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip }, nil } +// setupCompletion scores onboarding readiness across five equal-weight checks: +// brand profile, location, service catalog, staff roster, and schedule rules. +func setupCompletion(brandName string, locationCount, serviceCount, staffCount, scheduleCount int) int { + score := 0 + if strings.TrimSpace(brandName) != "" { + score += 20 + } + if locationCount > 0 { + score += 20 + } + if serviceCount > 0 { + score += 20 + } + if staffCount > 0 { + score += 20 + } + if scheduleCount > 0 { + score += 20 + } + return score +} + func widgetSnippets(tenant db.TenantRecord) []domain.WidgetSnippet { path := "/book/" + tenant.Slug return []domain.WidgetSnippet{ diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index 61f363f..a4624c9 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -18,6 +18,9 @@ var ( ErrBlockedDayNotFound = errors.New("blocked day not found") ErrCustomerNotFound = errors.New("customer not found") ErrBookingNotFound = errors.New("booking not found") + ErrServiceNotFound = errors.New("service not found") + ErrStaffNotFound = errors.New("staff member not found") + ErrInvalidInput = errors.New("invalid request") ErrInvalidBooking = errors.New("invalid booking request") ErrBookingConflict = errors.New("booking conflicts with an existing reservation") ErrTenantNotFound = errors.New("tenant not found") @@ -183,6 +186,310 @@ func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal return s.repo.DeleteLocation(ctx, locationID) } +// ============================================ +// SERVICE CATALOG MANAGEMENT +// ============================================ + +func serviceToDomain(rec db.ServiceRecord) domain.Service { + return domain.Service{ + ID: rec.ID, + TenantID: rec.TenantID, + Name: rec.Name, + DurationMinutes: rec.DurationMinutes, + BufferBeforeMinutes: rec.BufferBeforeMinutes, + BufferAfterMinutes: rec.BufferAfterMinutes, + PriceCents: rec.PriceCents, + CreatedAt: rec.CreatedAt, + } +} + +func validateServiceValues(name string, duration, bufferBefore, bufferAfter, priceCents int) error { + if trimmed := len(strings.TrimSpace(name)); trimmed < 1 || trimmed > 120 { + return fmt.Errorf("%w: name must be between 1 and 120 characters", ErrInvalidInput) + } + if duration <= 0 || duration > 24*60 { + return fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput) + } + if bufferBefore < 0 || bufferBefore > 480 { + return fmt.Errorf("%w: bufferBeforeMinutes must be between 0 and 480", ErrInvalidInput) + } + if bufferAfter < 0 || bufferAfter > 480 { + return fmt.Errorf("%w: bufferAfterMinutes must be between 0 and 480", ErrInvalidInput) + } + if priceCents < 0 { + return fmt.Errorf("%w: priceCents cannot be negative", ErrInvalidInput) + } + return nil +} + +func (s *Service) ListServices(ctx context.Context, principal domain.Principal) ([]domain.Service, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return nil, ErrTenantMembership + } + + records, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID) + if err != nil { + return nil, err + } + + services := make([]domain.Service, len(records)) + for i, rec := range records { + services[i] = serviceToDomain(rec) + } + return services, nil +} + +func (s *Service) CreateService(ctx context.Context, principal domain.Principal, req domain.CreateServiceRequest) (domain.Service, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return domain.Service{}, ErrTenantMembership + } + + name := strings.TrimSpace(req.Name) + if err := validateServiceValues(name, req.DurationMinutes, req.BufferBeforeMinutes, req.BufferAfterMinutes, req.PriceCents); err != nil { + return domain.Service{}, err + } + + rec, err := s.repo.CreateService(ctx, db.CreateServiceParams{ + TenantID: membership.Tenant.ID, + Name: name, + DurationMinutes: req.DurationMinutes, + BufferBeforeMinutes: req.BufferBeforeMinutes, + BufferAfterMinutes: req.BufferAfterMinutes, + PriceCents: req.PriceCents, + }) + if err != nil { + return domain.Service{}, err + } + return serviceToDomain(rec), nil +} + +func (s *Service) UpdateService(ctx context.Context, principal domain.Principal, serviceID string, req domain.UpdateServiceRequest) (domain.Service, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return domain.Service{}, ErrTenantMembership + } + + existing, err := s.repo.GetServiceByID(ctx, serviceID) + if err != nil || existing.TenantID != membership.Tenant.ID { + return domain.Service{}, ErrServiceNotFound + } + + params := db.UpdateServiceParams{} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + params.Name = &name + } + if req.DurationMinutes != nil { + params.DurationMinutes = req.DurationMinutes + } + if req.BufferBeforeMinutes != nil { + params.BufferBeforeMinutes = req.BufferBeforeMinutes + } + if req.BufferAfterMinutes != nil { + params.BufferAfterMinutes = req.BufferAfterMinutes + } + if req.PriceCents != nil { + params.PriceCents = req.PriceCents + } + + // Validate against the merged result so partial updates cannot produce an + // invalid record (e.g. setting duration to 0 while keeping the name). + merged := existing + if params.Name != nil { + merged.Name = *params.Name + } + if params.DurationMinutes != nil { + merged.DurationMinutes = *params.DurationMinutes + } + if params.BufferBeforeMinutes != nil { + merged.BufferBeforeMinutes = *params.BufferBeforeMinutes + } + if params.BufferAfterMinutes != nil { + merged.BufferAfterMinutes = *params.BufferAfterMinutes + } + if params.PriceCents != nil { + merged.PriceCents = *params.PriceCents + } + if err := validateServiceValues(merged.Name, merged.DurationMinutes, merged.BufferBeforeMinutes, merged.BufferAfterMinutes, merged.PriceCents); err != nil { + return domain.Service{}, err + } + + rec, err := s.repo.UpdateService(ctx, serviceID, params) + if err != nil { + return domain.Service{}, err + } + return serviceToDomain(rec), nil +} + +func (s *Service) DeleteService(ctx context.Context, principal domain.Principal, serviceID string) error { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return ErrTenantMembership + } + + existing, err := s.repo.GetServiceByID(ctx, serviceID) + if err != nil || existing.TenantID != membership.Tenant.ID { + return ErrServiceNotFound + } + + return s.repo.DeleteService(ctx, serviceID) +} + +// ============================================ +// STAFF MANAGEMENT +// ============================================ + +func staffToDomain(rec db.StaffRecord) domain.StaffMember { + return domain.StaffMember{ + ID: rec.ID, + TenantID: rec.TenantID, + LocationID: rec.LocationID, + DisplayName: rec.DisplayName, + Active: rec.Active, + CreatedAt: rec.CreatedAt, + } +} + +func validateStaffName(name string) error { + if trimmed := len(strings.TrimSpace(name)); trimmed < 1 || trimmed > 120 { + return fmt.Errorf("%w: displayName must be between 1 and 120 characters", ErrInvalidInput) + } + return nil +} + +// validateStaffLocation returns the normalized location reference: nil when the +// input is empty, the id when it belongs to the tenant, or an error otherwise. +func (s *Service) validateStaffLocation(ctx context.Context, tenantID string, locationID *string) (*string, error) { + if locationID == nil { + return nil, nil + } + trimmed := strings.TrimSpace(*locationID) + if trimmed == "" { + return nil, nil + } + loc, err := s.repo.GetLocationByID(ctx, trimmed) + if err != nil || loc.TenantID != tenantID { + return nil, fmt.Errorf("%w: locationId does not belong to this tenant", ErrInvalidInput) + } + return &trimmed, nil +} + +func (s *Service) ListStaff(ctx context.Context, principal domain.Principal) ([]domain.StaffMember, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return nil, ErrTenantMembership + } + + records, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID) + if err != nil { + return nil, err + } + + staff := make([]domain.StaffMember, len(records)) + for i, rec := range records { + staff[i] = staffToDomain(rec) + } + return staff, nil +} + +func (s *Service) CreateStaff(ctx context.Context, principal domain.Principal, req domain.CreateStaffRequest) (domain.StaffMember, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return domain.StaffMember{}, ErrTenantMembership + } + + if s.billingService != nil { + entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID) + if err == nil && entitlements.MaxStaff >= 0 { + staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID) + if err == nil && len(staff) >= entitlements.MaxStaff { + return domain.StaffMember{}, fmt.Errorf("%w: staff limit reached (%d/%d). Upgrade your plan to add more staff.", ErrPlanLimitReached, len(staff), entitlements.MaxStaff) + } + } + } + + name := strings.TrimSpace(req.DisplayName) + if err := validateStaffName(name); err != nil { + return domain.StaffMember{}, err + } + locationID, err := s.validateStaffLocation(ctx, membership.Tenant.ID, req.LocationID) + if err != nil { + return domain.StaffMember{}, err + } + + active := true + if req.Active != nil { + active = *req.Active + } + + rec, err := s.repo.CreateStaff(ctx, db.CreateStaffParams{ + TenantID: membership.Tenant.ID, + LocationID: locationID, + DisplayName: name, + Active: active, + }) + if err != nil { + return domain.StaffMember{}, err + } + return staffToDomain(rec), nil +} + +func (s *Service) UpdateStaff(ctx context.Context, principal domain.Principal, staffID string, req domain.UpdateStaffRequest) (domain.StaffMember, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return domain.StaffMember{}, ErrTenantMembership + } + + existing, err := s.repo.GetStaffByID(ctx, staffID) + if err != nil || existing.TenantID != membership.Tenant.ID { + return domain.StaffMember{}, ErrStaffNotFound + } + + params := db.UpdateStaffParams{Active: req.Active} + if req.DisplayName != nil { + name := strings.TrimSpace(*req.DisplayName) + if err := validateStaffName(name); err != nil { + return domain.StaffMember{}, err + } + params.DisplayName = &name + } + if req.LocationID != nil { + locationID, err := s.validateStaffLocation(ctx, membership.Tenant.ID, req.LocationID) + if err != nil { + return domain.StaffMember{}, err + } + // Keep the tri-state: an explicit empty string must clear the value. + if locationID == nil { + empty := "" + params.LocationID = &empty + } else { + params.LocationID = locationID + } + } + + rec, err := s.repo.UpdateStaff(ctx, staffID, params) + if err != nil { + return domain.StaffMember{}, err + } + return staffToDomain(rec), nil +} + +func (s *Service) DeleteStaff(ctx context.Context, principal domain.Principal, staffID string) error { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return ErrTenantMembership + } + + existing, err := s.repo.GetStaffByID(ctx, staffID) + if err != nil || existing.TenantID != membership.Tenant.ID { + return ErrStaffNotFound + } + + return s.repo.DeleteStaff(ctx, staffID) +} + // ============================================ // BLOCKED DAYS MANAGEMENT // ============================================ diff --git a/apps/backend/internal/catalog/service_test.go b/apps/backend/internal/catalog/service_test.go new file mode 100644 index 0000000..2cc3986 --- /dev/null +++ b/apps/backend/internal/catalog/service_test.go @@ -0,0 +1,261 @@ +package catalog + +import ( + "context" + "errors" + "testing" + + "bookra/apps/backend/internal/db" + "bookra/apps/backend/internal/domain" +) + +var owner = domain.Principal{Subject: "demo-owner", Email: "demo@bookra.dev", Role: "owner"} + +type stubBilling struct { + entitlements domain.PlanEntitlements +} + +func (s stubBilling) GetEntitlements(_ context.Context, _ string) (domain.PlanEntitlements, error) { + return s.entitlements, nil +} + +func newCatalogService(repo db.Repository) *Service { + return NewService(repo, stubBilling{entitlements: domain.PlanEntitlements{MaxStaff: -1}}, nil) +} + +func TestListServicesReturnsSeededService(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + + services, err := svc.ListServices(context.Background(), owner) + if err != nil { + t.Fatalf("list services: %v", err) + } + if len(services) != 1 { + t.Fatalf("expected 1 seeded service, got %d", len(services)) + } + if services[0].Name != "Signature treatment" { + t.Fatalf("unexpected service name %q", services[0].Name) + } +} + +func TestServiceCRUDLifecycle(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + created, err := svc.CreateService(ctx, owner, domain.CreateServiceRequest{ + Name: " Haircut ", + DurationMinutes: 45, + BufferBeforeMinutes: 5, + BufferAfterMinutes: 10, + PriceCents: 90000, + }) + if err != nil { + t.Fatalf("create service: %v", err) + } + if created.Name != "Haircut" { + t.Fatalf("expected trimmed name, got %q", created.Name) + } + if created.TenantID == "" { + t.Fatal("expected tenant id on created service") + } + + services, err := svc.ListServices(ctx, owner) + if err != nil { + t.Fatalf("list services: %v", err) + } + if len(services) != 2 { + t.Fatalf("expected 2 services, got %d", len(services)) + } + + zero := 0 + if _, err := svc.UpdateService(ctx, owner, created.ID, domain.UpdateServiceRequest{DurationMinutes: &zero}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput for zero duration, got %v", err) + } + + name := "Deluxe haircut" + price := 110000 + updated, err := svc.UpdateService(ctx, owner, created.ID, domain.UpdateServiceRequest{Name: &name, PriceCents: &price}) + if err != nil { + t.Fatalf("update service: %v", err) + } + if updated.Name != "Deluxe haircut" || updated.PriceCents != 110000 { + t.Fatalf("unexpected update result %+v", updated) + } + if updated.DurationMinutes != 45 || updated.BufferAfterMinutes != 10 { + t.Fatalf("partial update clobbered untouched fields %+v", updated) + } + + if err := svc.DeleteService(ctx, owner, created.ID); err != nil { + t.Fatalf("delete service: %v", err) + } + services, err = svc.ListServices(ctx, owner) + if err != nil { + t.Fatalf("list services: %v", err) + } + if len(services) != 1 { + t.Fatalf("expected 1 service after delete, got %d", len(services)) + } +} + +func TestServiceValidation(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + cases := []struct { + name string + req domain.CreateServiceRequest + }{ + {"empty name", domain.CreateServiceRequest{Name: " ", DurationMinutes: 30}}, + {"zero duration", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 0}}, + {"huge duration", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 2000}}, + {"negative buffer", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30, BufferBeforeMinutes: -1}}, + {"negative price", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30, PriceCents: -5}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := svc.CreateService(ctx, owner, tc.req); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput, got %v", err) + } + }) + } +} + +func TestServiceNotFoundAndMembership(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + missingID := "00000000-0000-0000-0000-000000000001" + if _, err := svc.UpdateService(ctx, owner, missingID, domain.UpdateServiceRequest{}); !errors.Is(err, ErrServiceNotFound) { + t.Fatalf("expected ErrServiceNotFound, got %v", err) + } + if err := svc.DeleteService(ctx, owner, missingID); !errors.Is(err, ErrServiceNotFound) { + t.Fatalf("expected ErrServiceNotFound, got %v", err) + } + + stranger := domain.Principal{Subject: "someone-else", Role: "owner"} + if _, err := svc.ListServices(ctx, stranger); !errors.Is(err, ErrTenantMembership) { + t.Fatalf("expected ErrTenantMembership, got %v", err) + } + if _, err := svc.CreateService(ctx, stranger, domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30}); !errors.Is(err, ErrTenantMembership) { + t.Fatalf("expected ErrTenantMembership, got %v", err) + } +} + +func TestStaffCRUDLifecycle(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + locations, err := svc.ListLocations(ctx, owner) + if err != nil || len(locations) == 0 { + t.Fatalf("expected seeded location, got %v err=%v", locations, err) + } + locationID := locations[0].ID + + created, err := svc.CreateStaff(ctx, owner, domain.CreateStaffRequest{ + DisplayName: " Anna Novak ", + LocationID: &locationID, + }) + if err != nil { + t.Fatalf("create staff: %v", err) + } + if created.DisplayName != "Anna Novak" { + t.Fatalf("expected trimmed name, got %q", created.DisplayName) + } + if !created.Active { + t.Fatal("new staff member should default to active") + } + if created.LocationID == nil || *created.LocationID != locationID { + t.Fatalf("expected location assignment %s, got %+v", locationID, created.LocationID) + } + + staff, err := svc.ListStaff(ctx, owner) + if err != nil { + t.Fatalf("list staff: %v", err) + } + if len(staff) != 2 { + t.Fatalf("expected seeded member + new member, got %d", len(staff)) + } + + inactive := false + cleared := "" + updated, err := svc.UpdateStaff(ctx, owner, created.ID, domain.UpdateStaffRequest{ + Active: &inactive, + LocationID: &cleared, + }) + if err != nil { + t.Fatalf("update staff: %v", err) + } + if updated.Active { + t.Fatal("expected staff member to be deactivated") + } + if updated.LocationID != nil { + t.Fatalf("expected cleared location, got %+v", updated.LocationID) + } + if updated.DisplayName != "Anna Novak" { + t.Fatalf("name should be untouched, got %q", updated.DisplayName) + } + + if err := svc.DeleteStaff(ctx, owner, created.ID); err != nil { + t.Fatalf("delete staff: %v", err) + } + staff, err = svc.ListStaff(ctx, owner) + if err != nil { + t.Fatalf("list staff: %v", err) + } + if len(staff) != 1 { + t.Fatalf("expected 1 staff member after delete, got %d", len(staff)) + } +} + +func TestCreateStaffEnforcesPlanLimit(t *testing.T) { + repo := db.NewMemoryRepository() + // Demo tenant seeds one member; a limit of 1 must reject a second. + svc := NewService(repo, stubBilling{entitlements: domain.PlanEntitlements{MaxStaff: 1}}, nil) + + _, err := svc.CreateStaff(context.Background(), owner, domain.CreateStaffRequest{DisplayName: "Second"}) + if !errors.Is(err, ErrPlanLimitReached) { + t.Fatalf("expected ErrPlanLimitReached, got %v", err) + } +} + +func TestCreateStaffRejectsForeignLocation(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + + foreign := "00000000-0000-0000-0000-000000000099" + _, err := svc.CreateStaff(context.Background(), owner, domain.CreateStaffRequest{ + DisplayName: "Anna", + LocationID: &foreign, + }) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput, got %v", err) + } +} + +func TestStaffNotFoundAndValidation(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + if _, err := svc.CreateStaff(ctx, owner, domain.CreateStaffRequest{DisplayName: " "}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput for blank name, got %v", err) + } + + missingID := "00000000-0000-0000-0000-000000000001" + if _, err := svc.UpdateStaff(ctx, owner, missingID, domain.UpdateStaffRequest{}); !errors.Is(err, ErrStaffNotFound) { + t.Fatalf("expected ErrStaffNotFound, got %v", err) + } + if err := svc.DeleteStaff(ctx, owner, missingID); !errors.Is(err, ErrStaffNotFound) { + t.Fatalf("expected ErrStaffNotFound, got %v", err) + } + + stranger := domain.Principal{Subject: "someone-else", Role: "staff"} + if _, err := svc.ListStaff(ctx, stranger); !errors.Is(err, ErrTenantMembership) { + t.Fatalf("expected ErrTenantMembership, got %v", err) + } +} diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 14c6d0a..fc164f8 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -65,6 +65,19 @@ type Repository interface { UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error) DeleteLocation(ctx context.Context, locationID string) error + // Service Management + GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error) + CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error) + UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) + DeleteService(ctx context.Context, serviceID string) error + + // Staff Management + ListStaffByTenant(ctx context.Context, tenantID string) ([]StaffRecord, error) + GetStaffByID(ctx context.Context, staffID string) (StaffRecord, error) + CreateStaff(ctx context.Context, params CreateStaffParams) (StaffRecord, error) + UpdateStaff(ctx context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) + DeleteStaff(ctx context.Context, staffID string) error + // Blocked Days / Availability Exceptions ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error) CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error) @@ -225,6 +238,50 @@ type ServiceRecord struct { BufferBeforeMinutes int BufferAfterMinutes int PriceCents int + CreatedAt time.Time +} + +type CreateServiceParams struct { + TenantID string + Name string + DurationMinutes int + BufferBeforeMinutes int + BufferAfterMinutes int + PriceCents int +} + +// Pointer fields distinguish "not sent" (keep current value) from an explicit +// zero value, which is meaningful for buffers and price. +type UpdateServiceParams struct { + Name *string + DurationMinutes *int + BufferBeforeMinutes *int + BufferAfterMinutes *int + PriceCents *int +} + +type StaffRecord struct { + ID string + TenantID string + LocationID *string + DisplayName string + Active bool + CreatedAt time.Time +} + +type CreateStaffParams struct { + TenantID string + LocationID *string + DisplayName string + Active bool +} + +// LocationID tri-state: nil keeps the current assignment, a pointer to an +// empty string clears it, a pointer to an id reassigns it. +type UpdateStaffParams struct { + DisplayName *string + LocationID *string + Active *bool } type AvailabilityRuleRecord struct { @@ -670,6 +727,7 @@ type MemoryRepository struct { billingSnapshot BillingSnapshotRecord recordedEvents map[string]struct{} locations []LocationRecord + staff []StaffRecord blockedDays []BlockedDayRecord customers []CustomerRecord workingHours []WorkingHoursRecord @@ -732,6 +790,17 @@ func NewMemoryRepository() *MemoryRepository { BufferBeforeMinutes: 0, BufferAfterMinutes: 15, PriceCents: 120000, + CreatedAt: now, + }, + }, + staff: []StaffRecord{ + { + ID: staffID, + TenantID: tenantID, + LocationID: &locationID, + DisplayName: "Demo Stylist", + Active: true, + CreatedAt: now, }, }, rules: []AvailabilityRuleRecord{ @@ -857,6 +926,7 @@ func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateT BufferAfterMinutes: maxInt(params.BufferAfterMinutes, 0), PriceCents: 0, }} + r.staff = nil r.rules = nil blocks := params.AvailabilityBlocks if len(blocks) == 0 { @@ -937,6 +1007,153 @@ func (r *MemoryRepository) ListServicesByTenant(_ context.Context, tenantID stri return append([]ServiceRecord(nil), r.services...), nil } +func (r *MemoryRepository) GetServiceByID(_ context.Context, serviceID string) (ServiceRecord, error) { + for _, service := range r.services { + if service.ID == serviceID { + return service, nil + } + } + return ServiceRecord{}, pgx.ErrNoRows +} + +func (r *MemoryRepository) CreateService(_ context.Context, params CreateServiceParams) (ServiceRecord, error) { + service := ServiceRecord{ + ID: uuid.NewString(), + TenantID: params.TenantID, + Name: params.Name, + DurationMinutes: params.DurationMinutes, + BufferBeforeMinutes: params.BufferBeforeMinutes, + BufferAfterMinutes: params.BufferAfterMinutes, + PriceCents: params.PriceCents, + CreatedAt: time.Now().UTC(), + } + r.services = append(r.services, service) + return service, nil +} + +func (r *MemoryRepository) UpdateService(_ context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) { + for i, service := range r.services { + if service.ID != serviceID { + continue + } + if params.Name != nil { + service.Name = *params.Name + } + if params.DurationMinutes != nil { + service.DurationMinutes = *params.DurationMinutes + } + if params.BufferBeforeMinutes != nil { + service.BufferBeforeMinutes = *params.BufferBeforeMinutes + } + if params.BufferAfterMinutes != nil { + service.BufferAfterMinutes = *params.BufferAfterMinutes + } + if params.PriceCents != nil { + service.PriceCents = *params.PriceCents + } + r.services[i] = service + return service, nil + } + return ServiceRecord{}, pgx.ErrNoRows +} + +func (r *MemoryRepository) DeleteService(_ context.Context, serviceID string) error { + for i, service := range r.services { + if service.ID != serviceID { + continue + } + r.services = append(r.services[:i], r.services[i+1:]...) + // Mirror ON DELETE SET NULL for bookings referencing the service. + for j, booking := range r.bookings { + if booking.ServiceID != nil && *booking.ServiceID == serviceID { + booking.ServiceID = nil + r.bookings[j] = booking + } + } + return nil + } + return pgx.ErrNoRows +} + +func (r *MemoryRepository) ListStaffByTenant(_ context.Context, tenantID string) ([]StaffRecord, error) { + if tenantID != r.tenant.ID { + return nil, nil + } + return append([]StaffRecord(nil), r.staff...), nil +} + +func (r *MemoryRepository) GetStaffByID(_ context.Context, staffID string) (StaffRecord, error) { + for _, member := range r.staff { + if member.ID == staffID { + return member, nil + } + } + return StaffRecord{}, pgx.ErrNoRows +} + +func (r *MemoryRepository) CreateStaff(_ context.Context, params CreateStaffParams) (StaffRecord, error) { + member := StaffRecord{ + ID: uuid.NewString(), + TenantID: params.TenantID, + LocationID: params.LocationID, + DisplayName: params.DisplayName, + Active: params.Active, + CreatedAt: time.Now().UTC(), + } + r.staff = append(r.staff, member) + return member, nil +} + +func (r *MemoryRepository) UpdateStaff(_ context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) { + for i, member := range r.staff { + if member.ID != staffID { + continue + } + if params.DisplayName != nil { + member.DisplayName = *params.DisplayName + } + if params.LocationID != nil { + locationID := strings.TrimSpace(*params.LocationID) + if locationID == "" { + member.LocationID = nil + } else { + member.LocationID = &locationID + } + } + if params.Active != nil { + member.Active = *params.Active + } + r.staff[i] = member + return member, nil + } + return StaffRecord{}, pgx.ErrNoRows +} + +func (r *MemoryRepository) DeleteStaff(_ context.Context, staffID string) error { + for i, member := range r.staff { + if member.ID != staffID { + continue + } + r.staff = append(r.staff[:i], r.staff[i+1:]...) + // Mirror the FK behaviour: bookings SET NULL, availability rules CASCADE. + for j, booking := range r.bookings { + if booking.StaffID != nil && *booking.StaffID == staffID { + booking.StaffID = nil + r.bookings[j] = booking + } + } + var rules []AvailabilityRuleRecord + for _, rule := range r.rules { + if rule.StaffID == nil || *rule.StaffID != staffID { + rules = append(rules, rule) + } + } + r.rules = rules + return nil + } + return pgx.ErrNoRows +} + func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) { if tenantID != r.tenant.ID { return nil, nil diff --git a/apps/backend/internal/db/services.go b/apps/backend/internal/db/services.go index 2cc7538..f9bc31b 100644 --- a/apps/backend/internal/db/services.go +++ b/apps/backend/internal/db/services.go @@ -5,9 +5,11 @@ import ( "time" ) +const serviceColumns = "id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents, created_at" + func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) { rows, err := r.pool.Query(ctx, ` - SELECT id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents + SELECT `+serviceColumns+` FROM services WHERE tenant_id = $1 ORDER BY created_at ASC @@ -28,6 +30,7 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string &record.BufferBeforeMinutes, &record.BufferAfterMinutes, &record.PriceCents, + &record.CreatedAt, ); err != nil { return nil, err } @@ -36,6 +39,73 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string return records, rows.Err() } +func (r *PGRepository) GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error) { + var record ServiceRecord + err := r.pool.QueryRow(ctx, ` + SELECT `+serviceColumns+` + FROM services + WHERE id = $1 + `, serviceID).Scan( + &record.ID, + &record.TenantID, + &record.Name, + &record.DurationMinutes, + &record.BufferBeforeMinutes, + &record.BufferAfterMinutes, + &record.PriceCents, + &record.CreatedAt, + ) + return record, err +} + +func (r *PGRepository) CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error) { + var record ServiceRecord + err := r.pool.QueryRow(ctx, ` + INSERT INTO services (tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING `+serviceColumns+` + `, params.TenantID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan( + &record.ID, + &record.TenantID, + &record.Name, + &record.DurationMinutes, + &record.BufferBeforeMinutes, + &record.BufferAfterMinutes, + &record.PriceCents, + &record.CreatedAt, + ) + return record, err +} + +func (r *PGRepository) UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) { + var record ServiceRecord + err := r.pool.QueryRow(ctx, ` + UPDATE services + SET name = COALESCE($2, name), + duration_minutes = COALESCE($3, duration_minutes), + buffer_before_minutes = COALESCE($4, buffer_before_minutes), + buffer_after_minutes = COALESCE($5, buffer_after_minutes), + price_cents = COALESCE($6, price_cents) + WHERE id = $1 + RETURNING `+serviceColumns+` + `, serviceID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan( + &record.ID, + &record.TenantID, + &record.Name, + &record.DurationMinutes, + &record.BufferBeforeMinutes, + &record.BufferAfterMinutes, + &record.PriceCents, + &record.CreatedAt, + ) + return record, err +} + +func (r *PGRepository) DeleteService(ctx context.Context, serviceID string) error { + _, err := r.pool.Exec(ctx, `DELETE FROM services WHERE id = $1`, serviceID) + return err +} + func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) { rows, err := r.pool.Query(ctx, ` SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local diff --git a/apps/backend/internal/db/staff.go b/apps/backend/internal/db/staff.go new file mode 100644 index 0000000..d4d25e9 --- /dev/null +++ b/apps/backend/internal/db/staff.go @@ -0,0 +1,79 @@ +package db + +import ( + "context" + "strings" +) + +const staffColumns = "id, tenant_id, location_id, display_name, active, created_at" + +func (r *PGRepository) ListStaffByTenant(ctx context.Context, tenantID string) ([]StaffRecord, error) { + rows, err := r.pool.Query(ctx, ` + SELECT `+staffColumns+` + FROM staff + WHERE tenant_id = $1 + ORDER BY created_at ASC + `, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []StaffRecord + for rows.Next() { + var rec StaffRecord + if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt); err != nil { + return nil, err + } + records = append(records, rec) + } + return records, rows.Err() +} + +func (r *PGRepository) GetStaffByID(ctx context.Context, staffID string) (StaffRecord, error) { + var rec StaffRecord + err := r.pool.QueryRow(ctx, ` + SELECT `+staffColumns+` + FROM staff + WHERE id = $1 + `, staffID).Scan(&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt) + return rec, err +} + +func (r *PGRepository) CreateStaff(ctx context.Context, params CreateStaffParams) (StaffRecord, error) { + var rec StaffRecord + err := r.pool.QueryRow(ctx, ` + INSERT INTO staff (tenant_id, location_id, display_name, active) + VALUES ($1, $2, $3, $4) + RETURNING `+staffColumns+` + `, params.TenantID, params.LocationID, params.DisplayName, params.Active).Scan( + &rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt) + return rec, err +} + +func (r *PGRepository) UpdateStaff(ctx context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) { + // Tri-state location update: untouched when the request omits the field, + // NULL when it is explicitly empty, otherwise reassigned. + locationSet := params.LocationID != nil + var locationValue any + if locationSet && strings.TrimSpace(*params.LocationID) != "" { + locationValue = strings.TrimSpace(*params.LocationID) + } + + var rec StaffRecord + err := r.pool.QueryRow(ctx, ` + UPDATE staff + SET display_name = COALESCE($2, display_name), + location_id = CASE WHEN $3 THEN $4::uuid ELSE location_id END, + active = COALESCE($5, active) + WHERE id = $1 + RETURNING `+staffColumns+` + `, staffID, params.DisplayName, locationSet, locationValue, params.Active).Scan( + &rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt) + return rec, err +} + +func (r *PGRepository) DeleteStaff(ctx context.Context, staffID string) error { + _, err := r.pool.Exec(ctx, `DELETE FROM staff WHERE id = $1`, staffID) + return err +} diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 873d293..b7287f9 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -267,6 +267,62 @@ type UpdateLocationRequest struct { Capacity int `json:"capacity,omitempty"` } +// ============================================ +// SERVICE MODELS +// ============================================ + +type Service struct { + ID string `json:"id"` + TenantID string `json:"tenantId"` + Name string `json:"name"` + DurationMinutes int `json:"durationMinutes"` + BufferBeforeMinutes int `json:"bufferBeforeMinutes"` + BufferAfterMinutes int `json:"bufferAfterMinutes"` + PriceCents int `json:"priceCents"` + CreatedAt time.Time `json:"createdAt"` +} + +type CreateServiceRequest struct { + Name string `json:"name" binding:"required"` + DurationMinutes int `json:"durationMinutes" binding:"required"` + BufferBeforeMinutes int `json:"bufferBeforeMinutes,omitempty"` + BufferAfterMinutes int `json:"bufferAfterMinutes,omitempty"` + PriceCents int `json:"priceCents,omitempty"` +} + +type UpdateServiceRequest struct { + Name *string `json:"name,omitempty"` + DurationMinutes *int `json:"durationMinutes,omitempty"` + BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"` + BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"` + PriceCents *int `json:"priceCents,omitempty"` +} + +// ============================================ +// STAFF MODELS +// ============================================ + +type StaffMember struct { + ID string `json:"id"` + TenantID string `json:"tenantId"` + LocationID *string `json:"locationId,omitempty"` + DisplayName string `json:"displayName"` + Active bool `json:"active"` + CreatedAt time.Time `json:"createdAt"` +} + +type CreateStaffRequest struct { + DisplayName string `json:"displayName" binding:"required"` + LocationID *string `json:"locationId,omitempty"` + Active *bool `json:"active,omitempty"` // defaults to true +} + +type UpdateStaffRequest struct { + DisplayName *string `json:"displayName,omitempty"` + LocationID *string `json:"locationId,omitempty"` // empty string clears the assignment + Active *bool `json:"active,omitempty"` +} + // ============================================ // BLOCKED DAYS / AVAILABILITY EXCEPTION MODELS // ============================================ diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index b68df44..d594edc 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -589,6 +589,156 @@ paths: description: Location deleted "404": description: Location not found + /v1/catalog/services: + get: + tags: [Catalog] + operationId: listServices + security: + - bearerAuth: [] + responses: + "200": + description: Tenant services + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Service" + "401": + description: Unauthorized + post: + tags: [Catalog] + operationId: createService + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertServiceRequest" + responses: + "201": + description: Service created + content: + application/json: + schema: + $ref: "#/components/schemas/Service" + "400": + description: Invalid request + /v1/catalog/services/{serviceID}: + put: + tags: [Catalog] + operationId: updateService + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/serviceID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertServiceRequest" + responses: + "200": + description: Service updated + content: + application/json: + schema: + $ref: "#/components/schemas/Service" + "400": + description: Invalid request + "404": + description: Service not found + delete: + tags: [Catalog] + operationId: deleteService + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/serviceID" + responses: + "200": + description: Service deleted + "404": + description: Service not found + /v1/catalog/staff: + get: + tags: [Catalog] + operationId: listStaff + security: + - bearerAuth: [] + responses: + "200": + description: Tenant staff + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/StaffMember" + "401": + description: Unauthorized + post: + tags: [Catalog] + operationId: createStaff + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertStaffRequest" + responses: + "201": + description: Staff member created + content: + application/json: + schema: + $ref: "#/components/schemas/StaffMember" + "400": + description: Invalid request + "403": + description: Plan limit reached + /v1/catalog/staff/{staffID}: + put: + tags: [Catalog] + operationId: updateStaff + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/staffID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertStaffRequest" + responses: + "200": + description: Staff member updated + content: + application/json: + schema: + $ref: "#/components/schemas/StaffMember" + "400": + description: Invalid request + "404": + description: Staff member not found + delete: + tags: [Catalog] + operationId: deleteStaff + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/staffID" + responses: + "200": + description: Staff member deleted + "404": + description: Staff member not found /v1/catalog/bookings: post: tags: [Catalog] @@ -1045,6 +1195,20 @@ components: schema: type: string format: uuid + serviceID: + in: path + name: serviceID + required: true + schema: + type: string + format: uuid + staffID: + in: path + name: staffID + required: true + schema: + type: string + format: uuid schemas: HealthResponse: type: object @@ -1478,6 +1642,81 @@ components: type: integer address: type: string + Service: + type: object + required: [id, tenantId, name, durationMinutes, bufferBeforeMinutes, bufferAfterMinutes, priceCents] + properties: + id: + type: string + format: uuid + tenantId: + type: string + format: uuid + name: + type: string + durationMinutes: + type: integer + description: Appointment length in minutes (1-1440) + bufferBeforeMinutes: + type: integer + description: Blocked time before the appointment (0-480) + bufferAfterMinutes: + type: integer + description: Blocked time after the appointment (0-480) + priceCents: + type: integer + description: Price in the tenant's currency minor units + createdAt: + type: string + format: date-time + UpsertServiceRequest: + type: object + required: [name, durationMinutes] + properties: + name: + type: string + durationMinutes: + type: integer + bufferBeforeMinutes: + type: integer + bufferAfterMinutes: + type: integer + priceCents: + type: integer + StaffMember: + type: object + required: [id, tenantId, displayName, active] + properties: + id: + type: string + format: uuid + tenantId: + type: string + format: uuid + locationId: + type: string + format: uuid + nullable: true + displayName: + type: string + active: + type: boolean + createdAt: + type: string + format: date-time + UpsertStaffRequest: + type: object + required: [displayName] + properties: + displayName: + type: string + locationId: + type: string + format: uuid + nullable: true + description: Omit to keep the current assignment; empty string clears it + active: + type: boolean BlockedDay: type: object required: [id, date, type] diff --git a/apps/frontend/src/components/dashboard/icons.tsx b/apps/frontend/src/components/dashboard/icons.tsx index 4247e58..c55580f 100644 --- a/apps/frontend/src/components/dashboard/icons.tsx +++ b/apps/frontend/src/components/dashboard/icons.tsx @@ -114,6 +114,12 @@ export const PlusIcon = () => ( ); +export const ScissorsIcon = () => ( + + + +); + export const UsersIcon = () => ( diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 1757fe3..eb196ae 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -16,6 +16,8 @@ const DashboardRoute = lazy(() => import("./routes/dashboard/overview-page")); const DashboardBookingsRoute = lazy(() => import("./routes/dashboard/bookings-page")); const DashboardCustomersRoute = lazy(() => import("./routes/dashboard/customers-page")); const DashboardZonesRoute = lazy(() => import("./routes/dashboard/zones-page")); +const DashboardServicesRoute = lazy(() => import("./routes/dashboard/services-page")); +const DashboardStaffRoute = lazy(() => import("./routes/dashboard/staff-page")); const DashboardBillingRoute = lazy(() => import("./routes/dashboard/billing-page")); const DashboardSettingsRoute = lazy(() => import("./routes/dashboard/settings-page")); const PublicBookingRoute = lazy(() => import("./routes/public-booking-route").then((module) => ({ default: module.PublicBookingRoute }))); @@ -35,6 +37,8 @@ render( + + diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 80da44f..91c8b88 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -398,6 +398,41 @@ const dictionaries = { "dashboard.zone.addBlocked": "Přidat blokovaný den", "dashboard.zone.reason": "Důvod", "dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.", + "dashboard.zone.staff": "Zaměstnanec", + "dashboard.zone.allStaff": "Všichni zaměstnanci", + "dashboard.services": "Služby", + "dashboard.services.subtitle": "služeb v nabídce", + "dashboard.service.add": "Přidat službu", + "dashboard.service.edit": "Upravit službu", + "dashboard.service.name": "Název služby", + "dashboard.service.namePlaceholder": "např. Dámský střih", + "dashboard.service.duration": "Délka (min)", + "dashboard.service.bufferBefore": "Přestávka před", + "dashboard.service.bufferAfter": "Přestávka po", + "dashboard.service.price": "Cena (Kč)", + "dashboard.service.free": "Zdarma", + "dashboard.service.min": "min", + "dashboard.service.noServices": "Zatím žádné služby.", + "dashboard.service.noServicesDesc": "Přidejte první službu, kterou si klienti mohou rezervovat.", + "dashboard.service.saveFailed": "Uložení služby selhalo.", + "dashboard.service.deleteFailed": "Smazání služby selhalo.", + "dashboard.staff": "Zaměstnanci", + "dashboard.staff.subtitle": "členů týmu", + "dashboard.staff.add": "Přidat zaměstnance", + "dashboard.staff.edit": "Upravit zaměstnance", + "dashboard.staff.name": "Jméno", + "dashboard.staff.namePlaceholder": "např. Anna Nováková", + "dashboard.staff.location": "Lokace", + "dashboard.staff.noLocation": "Bez lokace", + "dashboard.staff.active": "Aktivní", + "dashboard.staff.inactive": "Neaktivní", + "dashboard.staff.noStaff": "Zatím žádní zaměstnanci.", + "dashboard.staff.noStaffDesc": "Přidejte členy týmu, kteří poskytují služby.", + "dashboard.staff.limitReached": "Dosažen limit zaměstnanců", + "dashboard.staff.limitDesc": "Váš plán omezuje počet zaměstnanců. Pro více míst upgradujte.", + "dashboard.staff.saveFailed": "Uložení zaměstnance selhalo.", + "dashboard.staff.deleteFailed": "Smazání zaměstnance selhalo.", + "dashboard.bookingModal.selectService": "Vyberte službu", "dashboard.billing.planUsage": "Využití plánu", "dashboard.billing.locations": "Lokace", "dashboard.billing.bookings": "Rezervace", @@ -907,6 +942,41 @@ const dictionaries = { "dashboard.zone.addBlocked": "Add blocked day", "dashboard.zone.reason": "Reason", "dashboard.zone.noZones": "No zones yet. Add your first one.", + "dashboard.zone.staff": "Staff member", + "dashboard.zone.allStaff": "All staff", + "dashboard.services": "Services", + "dashboard.services.subtitle": "services offered", + "dashboard.service.add": "Add service", + "dashboard.service.edit": "Edit service", + "dashboard.service.name": "Service name", + "dashboard.service.namePlaceholder": "e.g. Women's haircut", + "dashboard.service.duration": "Duration (min)", + "dashboard.service.bufferBefore": "Buffer before", + "dashboard.service.bufferAfter": "Buffer after", + "dashboard.service.price": "Price (CZK)", + "dashboard.service.free": "Free", + "dashboard.service.min": "min", + "dashboard.service.noServices": "No services yet.", + "dashboard.service.noServicesDesc": "Add the first service clients can book.", + "dashboard.service.saveFailed": "Failed to save service.", + "dashboard.service.deleteFailed": "Failed to delete service.", + "dashboard.staff": "Staff", + "dashboard.staff.subtitle": "team members", + "dashboard.staff.add": "Add staff member", + "dashboard.staff.edit": "Edit staff member", + "dashboard.staff.name": "Name", + "dashboard.staff.namePlaceholder": "e.g. Anna Novak", + "dashboard.staff.location": "Location", + "dashboard.staff.noLocation": "No location", + "dashboard.staff.active": "Active", + "dashboard.staff.inactive": "Inactive", + "dashboard.staff.noStaff": "No staff yet.", + "dashboard.staff.noStaffDesc": "Add team members who provide services.", + "dashboard.staff.limitReached": "Staff limit reached", + "dashboard.staff.limitDesc": "Your plan limits the number of staff. Upgrade for more seats.", + "dashboard.staff.saveFailed": "Failed to save staff member.", + "dashboard.staff.deleteFailed": "Failed to delete staff member.", + "dashboard.bookingModal.selectService": "Select service", "dashboard.billing.planUsage": "Plan usage", "dashboard.billing.locations": "Locations", "dashboard.billing.bookings": "Bookings", diff --git a/apps/frontend/src/routes/dashboard/bookings-page.tsx b/apps/frontend/src/routes/dashboard/bookings-page.tsx index 946e729..ff8d3e8 100644 --- a/apps/frontend/src/routes/dashboard/bookings-page.tsx +++ b/apps/frontend/src/routes/dashboard/bookings-page.tsx @@ -1,4 +1,4 @@ -import { Show, createSignal, createMemo } from "solid-js"; +import { Show, createSignal, createMemo, createResource } from "solid-js"; import { Input } from "../../components/ui/input"; import { Select } from "../../components/ui/select"; import { Textarea } from "../../components/ui/textarea"; @@ -25,6 +25,28 @@ function BookingsPage() { const resolvedAllBookings = () => data.resolvedSummary()?.allBookings ?? data.normalizedAllBookings() ?? []; + const [serviceOptions] = createResource( + () => [data.token(), showNewBooking()] as const, + async ([bearer, open]) => { + if (!bearer || !open) return []; + if (bearer.startsWith("demo.")) { + const names = data.i18n.locale() === "cs" + ? ["Masáž", "Kosmetika", "Fyzioterapie", "Manikúra"] + : ["Massage", "Cosmetics", "Physiotherapy", "Manicure"]; + return names.map((name) => ({ value: name, label: name })); + } + try { + const response = await apiClient.GET("/v1/catalog/services", { headers: { Authorization: `Bearer ${bearer}` } }); + return ((response.data as any[]) ?? []).map((s) => ({ + value: s.name, + label: `${s.name} · ${s.durationMinutes} min`, + })); + } catch { + return []; + } + }, + ); + const filteredBookings = createMemo(() => { let bookings = resolvedAllBookings(); if (filterStatus() !== "all") bookings = bookings.filter((b: any) => b.status === filterStatus()); @@ -224,7 +246,16 @@ function BookingsPage() {
setNewBookingCustomer(e.currentTarget.value)} /> setNewBookingEmail(e.currentTarget.value)} /> - setNewBookingService(e.currentTarget.value)} /> + 0} fallback={ + setNewBookingService(e.currentTarget.value)} /> + }> + setNewBookingDate(e.currentTarget.value)} /> setNewBookingTime(e.currentTarget.value)} /> diff --git a/apps/frontend/src/routes/dashboard/dashboard-data.ts b/apps/frontend/src/routes/dashboard/dashboard-data.ts index fd829bc..3a1eca2 100644 --- a/apps/frontend/src/routes/dashboard/dashboard-data.ts +++ b/apps/frontend/src/routes/dashboard/dashboard-data.ts @@ -4,7 +4,7 @@ import { useAuth } from "../../providers/auth-provider"; import { useI18n } from "../../providers/i18n-provider"; import { getInitials, getBookingDuration } from "../../components/dashboard/types"; -export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings"; +export type Section = "overview" | "bookings" | "customers" | "services" | "staff" | "zones" | "billing" | "settings"; function createDemoData(i18n: ReturnType) { const cs = i18n.locale() === "cs"; diff --git a/apps/frontend/src/routes/dashboard/layout.tsx b/apps/frontend/src/routes/dashboard/layout.tsx index dd053ee..0e8d27e 100644 --- a/apps/frontend/src/routes/dashboard/layout.tsx +++ b/apps/frontend/src/routes/dashboard/layout.tsx @@ -8,7 +8,7 @@ import { getInitials, getBookingDuration } from "../../components/dashboard/type import { LayoutDashboardIcon, CalendarDaysIcon, CreditCardIcon, Settings2Icon, LogOutIcon, MenuIcon, XIcon, SparklesIcon, GlobeIcon, SunIcon, MoonIcon, - UserCircleIcon, MapPinIcon + UserCircleIcon, MapPinIcon, ScissorsIcon, UsersIcon } from "../../components/dashboard/icons"; import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data"; @@ -18,6 +18,8 @@ const navItems = [ { id: "overview", label: "dashboard.overview", icon: LayoutDashboardIcon, path: "/dashboard" }, { id: "bookings", label: "dashboard.bookings", icon: CalendarDaysIcon, path: "/dashboard/bookings" }, { id: "customers", label: "dashboard.customers", icon: UserCircleIcon, path: "/dashboard/customers" }, + { id: "services", label: "dashboard.services", icon: ScissorsIcon, path: "/dashboard/services" }, + { id: "staff", label: "dashboard.staff", icon: UsersIcon, path: "/dashboard/staff" }, { id: "zones", label: "dashboard.zones", icon: MapPinIcon, path: "/dashboard/zones" }, { id: "billing", label: "dashboard.billing", icon: CreditCardIcon, path: "/dashboard/billing" }, { id: "settings", label: "dashboard.settings", icon: Settings2Icon, path: "/dashboard/settings" }, diff --git a/apps/frontend/src/routes/dashboard/services-page.tsx b/apps/frontend/src/routes/dashboard/services-page.tsx new file mode 100644 index 0000000..273c633 --- /dev/null +++ b/apps/frontend/src/routes/dashboard/services-page.tsx @@ -0,0 +1,204 @@ +import { For, Show, createResource, createSignal } from "solid-js"; +import { apiClient } from "../../lib/api-client"; +import { Input } from "../../components/ui/input"; +import { PlusIcon, ScissorsIcon, Trash2Icon, EditIcon, ClockIcon } from "../../components/dashboard/icons"; +import { DashboardLayout, useDashboardData } from "./layout"; + +interface ServiceForm { + name: string; + durationMinutes: string; + bufferBeforeMinutes: string; + bufferAfterMinutes: string; + price: string; +} + +const emptyForm: ServiceForm = { name: "", durationMinutes: "60", bufferBeforeMinutes: "0", bufferAfterMinutes: "0", price: "0" }; + +function ServicesPage() { + const data = useDashboardData(); + const cs = () => data.i18n.locale() === "cs"; + const [showForm, setShowForm] = createSignal(false); + const [editingId, setEditingId] = createSignal(null); + const [form, setForm] = createSignal({ ...emptyForm }); + const [saving, setSaving] = createSignal(false); + + const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } }); + const setField = (patch: Partial) => setForm({ ...form(), ...patch }); + + const [services, { refetch }] = createResource( + () => data.token(), + async (bearer) => { + if (!bearer) return []; + if (bearer.startsWith("demo.")) { + return [ + { id: "sv1", name: cs() ? "Dámský střih" : "Women's haircut", durationMinutes: 60, bufferBeforeMinutes: 0, bufferAfterMinutes: 15, priceCents: 89000 }, + { id: "sv2", name: cs() ? "Pánský střih" : "Men's haircut", durationMinutes: 45, bufferBeforeMinutes: 0, bufferAfterMinutes: 10, priceCents: 59000 }, + { id: "sv3", name: cs() ? "Barvení" : "Coloring", durationMinutes: 120, bufferBeforeMinutes: 10, bufferAfterMinutes: 15, priceCents: 199000 }, + { id: "sv4", name: cs() ? "Konzultace" : "Consultation", durationMinutes: 30, bufferBeforeMinutes: 0, bufferAfterMinutes: 0, priceCents: 0 }, + ]; + } + const response = await apiClient.GET("/v1/catalog/services", authHeaders()); + return (response.data as any[]) ?? []; + }, + ); + + const resolvedServices = () => services() ?? []; + + const formatPrice = (priceCents: number) => { + if (!priceCents) return data.i18n.t("dashboard.service.free"); + return new Intl.NumberFormat(cs() ? "cs-CZ" : "en-US", { style: "currency", currency: "CZK", maximumFractionDigits: 0 }).format(priceCents / 100); + }; + + const openCreate = () => { + setEditingId(null); + setForm({ ...emptyForm }); + setShowForm(true); + }; + + const openEdit = (service: any) => { + setEditingId(service.id); + setForm({ + name: service.name, + durationMinutes: String(service.durationMinutes ?? 60), + bufferBeforeMinutes: String(service.bufferBeforeMinutes ?? 0), + bufferAfterMinutes: String(service.bufferAfterMinutes ?? 0), + price: String((service.priceCents ?? 0) / 100), + }); + setShowForm(true); + }; + + const handleSave = async () => { + const bearer = data.token(); + const name = form().name.trim(); + const duration = parseInt(form().durationMinutes, 10); + if (!name || !duration || duration <= 0) return; + if (!bearer || bearer.startsWith("demo.")) { + data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); + return; + } + setSaving(true); + try { + const body = { + name, + durationMinutes: duration, + bufferBeforeMinutes: Math.max(0, parseInt(form().bufferBeforeMinutes, 10) || 0), + bufferAfterMinutes: Math.max(0, parseInt(form().bufferAfterMinutes, 10) || 0), + priceCents: Math.max(0, Math.round((parseFloat(form().price) || 0) * 100)), + }; + const id = editingId(); + const response = id + ? await apiClient.PUT("/v1/catalog/services/{serviceID}", { ...authHeaders(), params: { path: { serviceID: id } }, body }) + : await apiClient.POST("/v1/catalog/services", { ...authHeaders(), body }); + if (response.error) throw new Error("save failed"); + setShowForm(false); + setEditingId(null); + setForm({ ...emptyForm }); + void refetch(); + } catch { + data.setDemoNotice(data.i18n.t("dashboard.service.saveFailed")); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + const bearer = data.token(); + if (!bearer || bearer.startsWith("demo.")) { + data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); + return; + } + try { + await apiClient.DELETE("/v1/catalog/services/{serviceID}", { ...authHeaders(), params: { path: { serviceID: id } } }); + void refetch(); + } catch { + data.setDemoNotice(data.i18n.t("dashboard.service.deleteFailed")); + } + }; + + return ( +
+
+
+

{data.i18n.t("dashboard.services")}

+

{resolvedServices().length} {data.i18n.t("dashboard.services.subtitle")}

+
+ +
+ +
+ +
+

{editingId() ? data.i18n.t("dashboard.service.edit") : data.i18n.t("dashboard.service.add")}

+
+
+ setField({ name: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.service.namePlaceholder")} /> +
+ setField({ durationMinutes: e.currentTarget.value })} /> + setField({ price: e.currentTarget.value })} /> + setField({ bufferBeforeMinutes: e.currentTarget.value })} /> + setField({ bufferAfterMinutes: e.currentTarget.value })} /> +
+
+ + +
+
+
+ + {cs() ? "Načítání…" : "Loading…"}
+ }> + 0} fallback={ +
+
+

{data.i18n.t("dashboard.service.noServices")}

+

{data.i18n.t("dashboard.service.noServicesDesc")}

+
+ }> +
+ + {(service: any) => ( +
+
+
+
+

{service.name}

+

+ {service.durationMinutes} {data.i18n.t("dashboard.service.min")} + 0 || service.bufferAfterMinutes > 0}> + + • +{service.bufferBeforeMinutes ?? 0}/{service.bufferAfterMinutes ?? 0} {data.i18n.t("dashboard.service.min")} {cs() ? "rezerva" : "buffer"} + + +

+
+
+
+ + + +
+
+ )} +
+
+
+ +
+
+ ); +} + +export default function ServicesRoute() { + return ( + + + + ); +} diff --git a/apps/frontend/src/routes/dashboard/staff-page.tsx b/apps/frontend/src/routes/dashboard/staff-page.tsx new file mode 100644 index 0000000..9f2dbc6 --- /dev/null +++ b/apps/frontend/src/routes/dashboard/staff-page.tsx @@ -0,0 +1,264 @@ +import { For, Show, createResource, createSignal } from "solid-js"; +import { apiClient } from "../../lib/api-client"; +import { Input } from "../../components/ui/input"; +import { Select } from "../../components/ui/select"; +import { PlusIcon, UsersIcon, Trash2Icon, EditIcon, SparklesIcon, MapPinIcon } from "../../components/dashboard/icons"; +import { DashboardLayout, useDashboardData } from "./layout"; + +interface StaffForm { + displayName: string; + locationId: string; +} + +const emptyForm: StaffForm = { displayName: "", locationId: "" }; + +function StaffPage() { + const data = useDashboardData(); + const cs = () => data.i18n.locale() === "cs"; + const [showForm, setShowForm] = createSignal(false); + const [editingId, setEditingId] = createSignal(null); + const [form, setForm] = createSignal({ ...emptyForm }); + const [saving, setSaving] = createSignal(false); + const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false); + + const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } }); + const setField = (patch: Partial) => setForm({ ...form(), ...patch }); + + const [staff, { refetch }] = createResource( + () => data.token(), + async (bearer) => { + if (!bearer) return []; + if (bearer.startsWith("demo.")) { + return [ + { id: "st1", displayName: "Petra M.", active: true, locationId: "z1" }, + { id: "st2", displayName: "Lucie K.", active: true, locationId: "z2" }, + { id: "st3", displayName: cs() ? "Martin Hájek" : "Martin Hajek", active: false, locationId: null }, + ]; + } + const response = await apiClient.GET("/v1/catalog/staff", authHeaders()); + return (response.data as any[]) ?? []; + }, + ); + + const [locations] = createResource( + () => data.token(), + async (bearer) => { + if (!bearer) return []; + if (bearer.startsWith("demo.")) { + return [ + { id: "z1", name: cs() ? "Hlavní sál" : "Main Hall" }, + { id: "z2", name: cs() ? "Soukromá místnost A" : "Private Room A" }, + ]; + } + const response = await apiClient.GET("/v1/catalog/locations", authHeaders()); + return (response.data as any[]) ?? []; + }, + ); + + const resolvedStaff = () => staff() ?? []; + const resolvedLocations = () => locations() ?? []; + const staffLimit = () => data.resolvedBilling()?.entitlements?.maxStaff ?? -1; + const canAddStaff = () => staffLimit() < 0 || resolvedStaff().length < staffLimit(); + + const locationName = (id: string | null | undefined) => + resolvedLocations().find((l: any) => l.id === id)?.name ?? data.i18n.t("dashboard.staff.noLocation"); + + const locationOptions = () => [ + { value: "", label: data.i18n.t("dashboard.staff.noLocation") }, + ...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })), + ]; + + const openCreate = () => { + setEditingId(null); + setForm({ ...emptyForm }); + setShowForm(true); + }; + + const openEdit = (member: any) => { + setEditingId(member.id); + setForm({ displayName: member.displayName, locationId: member.locationId ?? "" }); + setShowForm(true); + }; + + const handleSave = async () => { + const bearer = data.token(); + const name = form().displayName.trim(); + if (!name) return; + if (!bearer || bearer.startsWith("demo.")) { + data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); + return; + } + setSaving(true); + try { + const id = editingId(); + const locationId = form().locationId; + const response = id + ? await apiClient.PUT("/v1/catalog/staff/{staffID}", { + ...authHeaders(), + params: { path: { staffID: id } }, + body: { displayName: name, locationId: locationId || "" }, + }) + : await apiClient.POST("/v1/catalog/staff", { + ...authHeaders(), + body: { displayName: name, ...(locationId ? { locationId } : {}) }, + }); + if (response.error) throw new Error("save failed"); + setShowForm(false); + setEditingId(null); + setForm({ ...emptyForm }); + void refetch(); + } catch { + data.setDemoNotice(data.i18n.t("dashboard.staff.saveFailed")); + } finally { + setSaving(false); + } + }; + + const handleToggleActive = async (member: any) => { + const bearer = data.token(); + if (!bearer || bearer.startsWith("demo.")) { + data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); + return; + } + try { + await apiClient.PUT("/v1/catalog/staff/{staffID}", { + ...authHeaders(), + params: { path: { staffID: member.id } }, + body: { displayName: member.displayName, active: !member.active }, + }); + void refetch(); + } catch { + data.setDemoNotice(data.i18n.t("dashboard.staff.saveFailed")); + } + }; + + const handleDelete = async (id: string) => { + const bearer = data.token(); + if (!bearer || bearer.startsWith("demo.")) { + data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); + return; + } + try { + await apiClient.DELETE("/v1/catalog/staff/{staffID}", { ...authHeaders(), params: { path: { staffID: id } } }); + void refetch(); + } catch { + data.setDemoNotice(data.i18n.t("dashboard.staff.deleteFailed")); + } + }; + + return ( +
+
+
+

{data.i18n.t("dashboard.staff")}

+

{resolvedStaff().length} {data.i18n.t("dashboard.staff.subtitle")}

+
+ setShowUpgradePrompt(true)} class="flex items-center gap-2 px-4 py-2 bg-canvas-subtle text-ink-muted border border-border rounded-lg text-sm opacity-80"> + + {data.i18n.t("dashboard.zone.limitReached")} + + }> + + +
+ +
+ +
+

{editingId() ? data.i18n.t("dashboard.staff.edit") : data.i18n.t("dashboard.staff.add")}

+
+ setField({ displayName: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.staff.namePlaceholder")} /> + setNewBlockedDate(e.currentTarget.value)} /> setNewBlockedReason(e.currentTarget.value)} placeholder={cs() ? "např. Státní svátek" : "e.g. Public holiday"} /> + setHoursScope(v)} + options={[ + { value: "", label: data.i18n.t("dashboard.zone.businessHours") }, + ...(staffMembers() ?? []).map((m: any) => ({ value: m.id, label: m.displayName })), + ]} + /> +
@@ -356,6 +474,108 @@ function ZonesPage() {
)} + {activeTab() === "classes" && ( +
+
+
+

{data.i18n.t("dashboard.class.templates")}

+

{data.i18n.t("dashboard.class.sessionHint")}

+
+
+
+ setNewTemplateTitle(e.currentTarget.value)} placeholder={data.i18n.t("dashboard.class.titlePlaceholder")} /> + setNewTemplateDuration(parseInt(e.currentTarget.value) || 60)} /> + setNewTemplateCapacity(parseInt(e.currentTarget.value) || 8)} /> +
+ +
+
+
+ 0} fallback={ +
+

{data.i18n.t("dashboard.class.noTemplates")}

+
+ }> +
+ + {(tpl: any) => ( +
+
+
+
+

{tpl.title}

+

{tpl.durationMinutes} {data.i18n.t("dashboard.service.min")} • {data.i18n.t("dashboard.zone.capacity")}: {tpl.capacity}

+
+
+ +
+ )} +
+
+
+
+ +
+
+

{data.i18n.t("dashboard.class.sessions")}

+
+
+
+ setNewSessionLocationId(v)} + options={[ + { value: "", label: data.i18n.t("dashboard.class.noLocation") }, + ...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })), + ]} + /> + setNewSessionStartsAt(e.currentTarget.value)} /> +
+ +
+
+
+ 0} fallback={ +
+

{data.i18n.t("dashboard.class.noSessions")}

+
+ }> +
+ + {(session: any) => ( +
+
+

{session.templateTitle}

+

+ {new Date(session.startsAt).toLocaleString(cs() ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })} + {session.locationName ? ` • ${session.locationName}` : ""} + {` • ${session.bookedCount}/${session.capacity} ${data.i18n.t("dashboard.class.booked")}`} +

+
+ +
+ )} +
+
+
+
+
+ )} +
setShowUpgradePrompt(false)} /> diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 6c45b3b..1f51cd6 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -287,6 +287,70 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/catalog/class-sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listClassSessions"]; + put?: never; + post: operations["createClassSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/catalog/class-sessions/{sessionID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteClassSession"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/catalog/class-templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listClassTemplates"]; + put?: never; + post: operations["createClassTemplate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/catalog/class-templates/{templateID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteClassTemplate"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/catalog/customers": { parameters: { query?: never; @@ -415,6 +479,38 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/catalog/staff/{staffID}/working-hours": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listStaffWorkingHours"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/catalog/staff/{staffID}/working-hours/{dayOfWeek}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateStaffWorkingHours"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/catalog/working-hours": { parameters: { query?: never; @@ -895,6 +991,35 @@ export interface components { */ planCode: "starter" | "pro" | "business"; }; + ClassSession: { + bookedCount: number; + capacity: number; + /** Format: date-time */ + endsAt: string; + /** Format: uuid */ + id: string; + /** Format: uuid */ + locationId?: string; + locationName?: string; + /** Format: date-time */ + startsAt: string; + /** Format: uuid */ + templateId: string; + templateTitle: string; + /** Format: uuid */ + tenantId: string; + }; + ClassTemplate: { + capacity: number; + /** Format: date-time */ + createdAt: string; + durationMinutes: number; + /** Format: uuid */ + id: string; + /** Format: uuid */ + tenantId: string; + title: string; + }; CreateBookingRequest: { /** @enum {string} */ bookingMode: "appointment" | "class"; @@ -925,6 +1050,15 @@ export interface components { /** @enum {string} */ status: "confirmed" | "waitlisted"; }; + CreateClassSessionRequest: { + capacity?: number; + /** Format: uuid */ + locationId?: string; + /** Format: date-time */ + startsAt: string; + /** Format: uuid */ + templateId: string; + }; Customer: { bookingsCount?: number; /** Format: email */ @@ -1222,6 +1356,11 @@ export interface components { /** @enum {string} */ type: "closure" | "holiday" | "maintenance"; }; + UpsertClassTemplateRequest: { + capacity: number; + durationMinutes: number; + title: string; + }; UpsertCustomerRequest: { /** Format: email */ email: string; @@ -1891,6 +2030,169 @@ export interface operations { }; }; }; + listClassSessions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Upcoming class sessions */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClassSession"][]; + }; + }; + }; + }; + createClassSession: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateClassSessionRequest"]; + }; + }; + responses: { + /** @description Class session created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClassSession"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Template or location not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteClassSession: { + parameters: { + query?: never; + header?: never; + path: { + sessionID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Class session deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Class session not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listClassTemplates: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Class templates */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClassTemplate"][]; + }; + }; + }; + }; + createClassTemplate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertClassTemplateRequest"]; + }; + }; + responses: { + /** @description Class template created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClassTemplate"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteClassTemplate: { + parameters: { + query?: never; + header?: never; + path: { + templateID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Class template deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Class template not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listCustomers: { parameters: { query?: { @@ -2380,6 +2682,74 @@ export interface operations { }; }; }; + listStaffWorkingHours: { + parameters: { + query?: never; + header?: never; + path: { + staffID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Staff member's weekly working hours */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkingHours"][]; + }; + }; + /** @description Staff member not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateStaffWorkingHours: { + parameters: { + query?: never; + header?: never; + path: { + dayOfWeek: number; + staffID: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateWorkingHoursRequest"]; + }; + }; + responses: { + /** @description Staff working hours updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Staff member not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listWorkingHours: { parameters: { query?: never; diff --git a/project.md b/project.md index adbcca6..458b2cf 100644 --- a/project.md +++ b/project.md @@ -51,7 +51,7 @@ - protected tenant onboarding endpoint added with backend validation for slug, preset, locale, and timezone - onboarding now creates the tenant, owner membership, and first default location - dashboard can transition from empty bootstrap shell to a provisioned tenant state - - post-onboarding entity screens for locations, services, staff, blocked days, and working hours now exist (Milestone 8); class/session template management is still missing + - post-onboarding entity screens for locations, services, staff, blocked days, working hours, and class templates/sessions now exist (Milestones 8 and 10) - `[done/partial]` Milestone 8: Catalog management shipped; live staging validation and operational hardening remain - service CRUD end-to-end: repository (pgx + in-memory), catalog service with validation and tenant isolation, protected `/v1/catalog/services` routes, OpenAPI schemas, dashboard services page - staff CRUD end-to-end with active toggle, optional location assignment, and `MaxStaff` entitlement enforcement; dashboard staff page with upgrade prompt @@ -65,7 +65,16 @@ - availability endpoint accepts `serviceId`, `staffId`, and `date` filters; default response is unchanged for the embeddable widget - slots now carry `staffName`, respect tenant-wide and per-staff blocked days, and are generated per-day without the preview cap when `date` is given - booking creation hardened: appointment requests must fit inside a matching availability rule, not overlap a blocked day, and staff/location IDs must belong to the tenant with staff active - - remaining: per-staff availability UI, class/session template management, live staging validation + - remaining: live staging validation +- `[done/partial]` Milestone 10: Per-staff availability and class/session management + - working-hours repository now accepts a nullable staff scope on list/update (pgx + in-memory); staff-scoped rules live in `availability_rules` and directly drive public slot generation + - protected routes added: `GET/PUT /v1/catalog/staff/{id}/working-hours[/{day}]` with tenant-ownership checks + - class template + session CRUD end-to-end: repository (`classes.go`), catalog service with validation, protected `/v1/catalog/class-templates` and `/v1/catalog/class-sessions` routes, OpenAPI + regenerated client + - sessions derive `endsAt` from template duration, default capacity from the template, validate future start, tenant-owned template and optional location; list resolves template title, location name, and live booked count + - deletion semantics mirror the schema: deleting a template cascades its sessions; deleting a session preserves bookings via `class_session_id SET NULL` (mirrored in the in-memory repo) + - dashboard zones page gained a staff-scope selector on the working-hours tab (business-wide vs individual schedule) and a Classes tab with template create/delete and session scheduling + - normalized `availability_rules` time columns (`HH:MM:SS`) down to the `HH:MM` API contract in the service layer + - remaining: live staging validation ## 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. From 668a54ae1d11addac3f85b8ef326aa1ccd131e6f Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 11:21:46 +0200 Subject: [PATCH 04/13] feat(bookings): dashboard cancellation and waitlist promotion - POST /v1/catalog/bookings/{id}/cancel lets businesses cancel tenant-owned bookings; two-step confirm in the detail modal - Shared PromoteWaitlist helper promotes earliest waitlisted bookings when a class seat frees, on both dashboard and customer cancel paths; promoted customers get a confirmation email, waitlist ledger marked resolved - waitlisted status surfaced in bookings UI: stats card, filter, warning badge, demo seed, CS+EN strings - BookingRecord gains created_at for fair ordering; GetBookingByID and ResolveWaitlistEntry added to the repository surface Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/internal/api/server.go | 15 +++ .../internal/bookings/customer_service.go | 48 ++++++++ apps/backend/internal/bookings/lifecycle.go | 64 +++++++++++ .../backend/internal/bookings/service_test.go | 51 +++++++++ apps/backend/internal/catalog/service.go | 108 ++++++++++++++++++ apps/backend/internal/catalog/service_test.go | 93 +++++++++++++++ apps/backend/internal/db/bookings.go | 14 ++- apps/backend/internal/db/repository.go | 37 +++++- apps/backend/openapi/bookra.openapi.yaml | 20 ++++ apps/frontend/src/providers/i18n-provider.tsx | 6 + .../src/routes/dashboard/bookings-page.tsx | 8 +- .../src/routes/dashboard/dashboard-data.ts | 1 + apps/frontend/src/routes/dashboard/layout.tsx | 46 ++++++++ packages/api-client/src/generated/types.ts | 50 ++++++++ project.md | 7 ++ 15 files changed, 563 insertions(+), 5 deletions(-) create mode 100644 apps/backend/internal/bookings/lifecycle.go diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 6831571..2aab16b 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -721,6 +721,21 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { } c.JSON(http.StatusCreated, response) }) + protected.POST("/catalog/bookings/:bookingID/cancel", func(c *gin.Context) { + err := catalogService.CancelBooking(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("bookingID")) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrBookingNotFound): + status = http.StatusNotFound + case errors.Is(err, catalog.ErrInvalidInput): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "cancelled"}) + }) // ============================================ // CATALOG API - Blocked Days diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index a8c8ac2..b587709 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -26,6 +26,7 @@ type CustomerService struct { } type CustomerNotifier interface { + SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error } @@ -42,6 +43,10 @@ func NewCustomerService(repo db.Repository, notifier CustomerNotifier, frontendU type customerNoopNotifier struct{} +func (n *customerNoopNotifier) SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error { + return nil +} + func (n *customerNoopNotifier) SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error { return nil } @@ -194,6 +199,15 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t // Send cancellation email (async) go s.sendCancellationEmail(booking) + // A freed class seat promotes the earliest waitlisted booking. + if booking.ClassSessionID != nil { + if promoted, err := PromoteWaitlist(ctx, s.repo, booking.TenantID, *booking.ClassSessionID); err == nil { + for _, promotedBooking := range promoted { + go s.sendPromotionEmail(promotedBooking) + } + } + } + return nil } @@ -270,3 +284,37 @@ func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) { s.notifier.SendBookingCancellation(ctx, emailData) } + +// sendPromotionEmail confirms a booking that moved off the class waitlist. +func (s *CustomerService) sendPromotionEmail(booking db.BookingRecord) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID) + if err != nil { + return + } + brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID) + brandColor := brand.PrimaryColor + if brandColor == "" { + brandColor = "#a65c3e" + } + serviceName, locationName := s.descriptors(ctx, booking) + + s.notifier.SendBookingConfirmation(ctx, notifications.BookingEmailData{ + TenantID: tenant.ID, + TenantName: tenant.Name, + TenantSlug: tenant.Slug, + BrandColor: brandColor, + CustomerName: booking.CustomerName, + CustomerEmail: booking.CustomerEmail, + Service: serviceName, + Location: locationName, + Reference: booking.Reference, + StartsAt: booking.StartsAt, + EndsAt: booking.EndsAt, + Timezone: tenant.Timezone, + Locale: tenant.Locale, + ManagementURL: s.managementURL(booking), + }) +} diff --git a/apps/backend/internal/bookings/lifecycle.go b/apps/backend/internal/bookings/lifecycle.go new file mode 100644 index 0000000..5909d30 --- /dev/null +++ b/apps/backend/internal/bookings/lifecycle.go @@ -0,0 +1,64 @@ +package bookings + +import ( + "context" + "errors" + "sort" + "time" + + "github.com/jackc/pgx/v5" + + "bookra/apps/backend/internal/db" +) + +// PromoteWaitlist confirms the earliest waitlisted bookings on a class session +// while free capacity remains. It returns the promoted records so callers can +// notify customers. A missing session is treated as nothing to promote. +func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionID string) ([]db.BookingRecord, error) { + session, err := repo.GetClassSessionByID(ctx, sessionID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + bookings, err := repo.ListBookingsByTenantBetween(ctx, tenantID, session.StartsAt.Add(-time.Second), session.EndsAt.Add(time.Second)) + if err != nil { + return nil, err + } + + confirmed := 0 + var waitlisted []db.BookingRecord + for _, booking := range bookings { + if booking.ClassSessionID == nil || *booking.ClassSessionID != sessionID { + continue + } + switch booking.Status { + case "confirmed": + confirmed++ + case "waitlisted": + waitlisted = append(waitlisted, booking) + } + } + if len(waitlisted) == 0 { + return nil, nil + } + sort.SliceStable(waitlisted, func(i, j int) bool { + return waitlisted[i].CreatedAt.Before(waitlisted[j].CreatedAt) + }) + + var promoted []db.BookingRecord + for _, booking := range waitlisted { + if session.Capacity > 0 && confirmed >= int(session.Capacity) { + break + } + if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil { + return promoted, err + } + _ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail) + confirmed++ + promoted = append(promoted, booking) + } + return promoted, nil +} diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index ce15fb6..e74ff19 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -3,6 +3,8 @@ package bookings import ( "context" "errors" + "fmt" + "net/url" "testing" "time" @@ -399,3 +401,52 @@ func TestCreateAppointmentRejectsForeignStaff(t *testing.T) { t.Fatalf("expected ErrInvalidBooking, got %v", err) } } + +func TestCustomerCancelPromotesWaitlist(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + customerService := NewCustomerService(repo, nil, "http://localhost:3000") + ctx := context.Background() + + session, err := repo.GetClassSessionByID(ctx, "4bf74c12-44dd-45ca-86bb-b104f16f2435") + if err != nil { + t.Fatalf("get session: %v", err) + } + sessionID := session.ID + book := func(email string) domain.CreateBookingResponse { + resp, err := service.Create(ctx, domain.CreateBookingRequest{ + TenantSlug: "studio-atelier", + BookingMode: "class", + ClassSessionID: &sessionID, + CustomerName: "Test User", + CustomerEmail: email, + StartsAt: session.StartsAt.UTC().Format(time.RFC3339), + EndsAt: session.EndsAt.UTC().Format(time.RFC3339), + }) + if err != nil { + t.Fatalf("book %s: %v", email, err) + } + return resp + } + + var confirmed []domain.CreateBookingResponse + for i := 0; i < 4; i++ { + confirmed = append(confirmed, book(fmt.Sprintf("seat%d@example.com", i))) + } + waiter := book("waiter@example.com") + if waiter.Status != "waitlisted" { + t.Fatalf("expected waitlisted, got %s", waiter.Status) + } + + // Customer cancels a confirmed seat via the management token. + manageURL, _ := url.Parse(confirmed[0].ManageURL) + token := manageURL.Query().Get("token") + if err := customerService.CancelBooking(ctx, confirmed[0].Reference, token); err != nil { + t.Fatalf("customer cancel: %v", err) + } + + booking, _ := repo.GetBookingByID(ctx, waiter.BookingID) + if booking.Status != "confirmed" { + t.Fatalf("expected waiter promoted to confirmed, got %q", booking.Status) + } +} diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index 9c1282b..eccdef8 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -11,6 +11,7 @@ import ( "bookra/apps/backend/internal/bookings" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" + "bookra/apps/backend/internal/notifications" "github.com/jackc/pgx/v5" ) @@ -39,6 +40,8 @@ type Service struct { } notificationService interface { SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error + SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error + SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error } } @@ -46,6 +49,8 @@ func NewService(repo db.Repository, billingService interface { GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error) }, notificationService interface { SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error + SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error + SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error }) *Service { return &Service{repo: repo, billingService: billingService, notificationService: notificationService} } @@ -1263,3 +1268,106 @@ func hhmm(value string) string { } return value } + +// ============================================ +// BOOKING LIFECYCLE +// ============================================ + +// CancelBooking cancels a tenant-owned booking from the dashboard. Cancelling a +// class booking frees the seat and promotes the earliest waitlisted bookings. +func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal, bookingID string) error { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return ErrTenantMembership + } + + booking, err := s.repo.GetBookingByID(ctx, bookingID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrBookingNotFound + } + return err + } + if booking.TenantID != membership.Tenant.ID { + return ErrBookingNotFound + } + if booking.Status == "cancelled" { + return fmt.Errorf("%w: booking already cancelled", ErrInvalidInput) + } + + if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil { + return err + } + + go s.sendBookingEmail(booking, false) + + if booking.ClassSessionID != nil { + promoted, err := bookings.PromoteWaitlist(ctx, s.repo, booking.TenantID, *booking.ClassSessionID) + if err != nil { + return nil + } + for _, promotedBooking := range promoted { + go s.sendBookingEmail(promotedBooking, true) + } + } + return nil +} + +// 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. +func (s *Service) sendBookingEmail(booking db.BookingRecord, promoted bool) { + if s.notificationService == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID) + if err != nil { + return + } + brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID) + brandColor := brand.PrimaryColor + if brandColor == "" { + 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 + } + } + if booking.LocationID != nil { + if location, err := s.repo.GetLocationByID(ctx, *booking.LocationID); err == nil { + locationName = location.Name + } + } + + data := notifications.BookingEmailData{ + TenantID: tenant.ID, + TenantName: tenant.Name, + TenantSlug: tenant.Slug, + BrandColor: brandColor, + CustomerName: booking.CustomerName, + CustomerEmail: booking.CustomerEmail, + Service: serviceName, + Location: locationName, + Reference: booking.Reference, + StartsAt: booking.StartsAt, + EndsAt: booking.EndsAt, + Timezone: tenant.Timezone, + Locale: tenant.Locale, + } + if promoted { + s.notificationService.SendBookingConfirmation(ctx, data) + } else { + s.notificationService.SendBookingCancellation(ctx, data) + } +} diff --git a/apps/backend/internal/catalog/service_test.go b/apps/backend/internal/catalog/service_test.go index b95a125..1d5adba 100644 --- a/apps/backend/internal/catalog/service_test.go +++ b/apps/backend/internal/catalog/service_test.go @@ -3,6 +3,7 @@ package catalog import ( "context" "errors" + "fmt" "testing" "time" @@ -547,3 +548,95 @@ func TestDeleteClassSessionPreservesBooking(t *testing.T) { t.Fatal("expected booking class_session_id to be nulled") } } + +const demoSessionID = "4bf74c12-44dd-45ca-86bb-b104f16f2435" + +func bookClass(t *testing.T, repo db.Repository, email string) domain.CreateBookingResponse { + t.Helper() + session, err := repo.GetClassSessionByID(context.Background(), demoSessionID) + if err != nil { + t.Fatalf("get session: %v", err) + } + bookingSvc := bookings.NewService(repo, nil, "") + resp, err := bookingSvc.Create(context.Background(), domain.CreateBookingRequest{ + TenantSlug: "studio-atelier", + CustomerName: "Test User", + CustomerEmail: email, + BookingMode: "class", + ClassSessionID: &[]string{demoSessionID}[0], + StartsAt: session.StartsAt.UTC().Format(time.RFC3339), + EndsAt: session.EndsAt.UTC().Format(time.RFC3339), + }) + if err != nil { + t.Fatalf("book class for %s: %v", email, err) + } + return resp +} + +func TestCancelBookingLifecycle(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + resp := bookClass(t, repo, "cancel-me@example.com") + + if err := svc.CancelBooking(ctx, owner, resp.BookingID); err != nil { + t.Fatalf("cancel booking: %v", err) + } + booking, _ := repo.GetBookingByID(ctx, resp.BookingID) + if booking.Status != "cancelled" { + t.Fatalf("expected cancelled, got %q", booking.Status) + } + + if err := svc.CancelBooking(ctx, owner, resp.BookingID); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput on second cancel, got %v", err) + } + if err := svc.CancelBooking(ctx, owner, "00000000-0000-0000-0000-000000000099"); !errors.Is(err, ErrBookingNotFound) { + t.Fatalf("expected ErrBookingNotFound, got %v", err) + } + stranger := domain.Principal{Subject: "someone-else", Role: "staff"} + if err := svc.CancelBooking(ctx, stranger, resp.BookingID); !errors.Is(err, ErrTenantMembership) { + t.Fatalf("expected ErrTenantMembership, got %v", err) + } +} + +func TestCancelBookingPromotesWaitlist(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + // Seeded session capacity is 4 — fill it, then overflow onto the waitlist. + var confirmed []domain.CreateBookingResponse + for i := 0; i < 4; i++ { + confirmed = append(confirmed, bookClass(t, repo, fmt.Sprintf("seat%d@example.com", i))) + } + first := bookClass(t, repo, "first-waiter@example.com") + second := bookClass(t, repo, "second-waiter@example.com") + + booking, _ := repo.GetBookingByID(ctx, first.BookingID) + if booking.Status != "waitlisted" { + t.Fatalf("expected waitlisted, got %q", booking.Status) + } + + // Cancel a confirmed seat -> earliest waiter is promoted, second stays queued. + if err := svc.CancelBooking(ctx, owner, confirmed[0].BookingID); err != nil { + t.Fatalf("cancel: %v", err) + } + booking, _ = repo.GetBookingByID(ctx, first.BookingID) + if booking.Status != "confirmed" { + t.Fatalf("expected first waiter promoted, got %q", booking.Status) + } + booking, _ = repo.GetBookingByID(ctx, second.BookingID) + if booking.Status != "waitlisted" { + t.Fatalf("expected second waiter still queued, got %q", booking.Status) + } + + // Free another seat -> second waiter promoted. + if err := svc.CancelBooking(ctx, owner, confirmed[1].BookingID); err != nil { + t.Fatalf("cancel second: %v", err) + } + booking, _ = repo.GetBookingByID(ctx, second.BookingID) + if booking.Status != "confirmed" { + t.Fatalf("expected second waiter promoted, got %q", booking.Status) + } +} diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 378ead2..1e0d92f 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -9,7 +9,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID rows, err := r.pool.Query(ctx, ` SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference, - COALESCE(manage_token, ''), COALESCE(notes, '') + COALESCE(manage_token, ''), COALESCE(notes, ''), created_at FROM bookings WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2 ORDER BY starts_at ASC @@ -38,6 +38,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID &record.Reference, &record.ManageToken, &record.Notes, + &record.CreatedAt, ); err != nil { return nil, err } @@ -84,6 +85,17 @@ func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistE return err } +// ResolveWaitlistEntry marks the pending ledger row for a promoted booking. +// Entries carry no booking_id, so the session + customer email pair is the +// practical key. +func (r *PGRepository) ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE waitlist_entries SET status = 'resolved' + WHERE tenant_id = $1 AND class_session_id = $2 AND customer_email = $3 AND status = 'pending' + `, tenantID, sessionID, customerEmail) + return err +} + func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error { _, err := r.pool.Exec(ctx, ` INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for) diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index b00cc5d..9cde1d2 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -36,6 +36,7 @@ type Repository interface { ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error + ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error CreateReminderJob(ctx context.Context, params ReminderJobParams) error ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error @@ -103,6 +104,7 @@ type Repository interface { // Customer Booking Management GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error) + GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error) UpdateBookingStatus(ctx context.Context, bookingID string, status string) error RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error @@ -352,6 +354,7 @@ type BookingRecord struct { Reference string ManageToken string Notes string + CreatedAt time.Time } type CreateBookingParams struct { @@ -719,11 +722,24 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri err := r.pool.QueryRow(ctx, ` SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference, - COALESCE(manage_token, ''), COALESCE(notes, '') + COALESCE(manage_token, ''), COALESCE(notes, ''), created_at FROM bookings WHERE reference = $1 `, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID, - &rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes) + &rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt) + return rec, err +} + +func (r *PGRepository) GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error) { + var rec BookingRecord + err := r.pool.QueryRow(ctx, ` + SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, + customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference, + COALESCE(manage_token, ''), COALESCE(notes, ''), created_at + FROM bookings + WHERE id = $1 + `, bookingID).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID, + &rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt) return rec, err } @@ -1343,6 +1359,7 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking Reference: params.Reference, ManageToken: params.ManageToken, Notes: params.Notes, + CreatedAt: time.Now().UTC(), }) return created, nil } @@ -1352,6 +1369,13 @@ func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntr return nil } +func (r *MemoryRepository) ResolveWaitlistEntry(_ context.Context, _, _, _ string) error { + if r.waitlistSize > 0 { + r.waitlistSize-- + } + return nil +} + func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error { reference := params.BookingID startsAt := time.Now().UTC() @@ -1756,6 +1780,15 @@ func (r *MemoryRepository) GetBookingByReference(_ context.Context, reference st return BookingRecord{}, pgx.ErrNoRows } +func (r *MemoryRepository) GetBookingByID(_ context.Context, bookingID string) (BookingRecord, error) { + for _, b := range r.bookings { + if b.ID == bookingID { + return b, nil + } + } + return BookingRecord{}, pgx.ErrNoRows +} + func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error { for i, b := range r.bookings { if b.ID == bookingID { diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 2fb2f5b..3fb2345 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -800,6 +800,26 @@ paths: description: Invalid request "409": description: Conflict with existing booking + /v1/catalog/bookings/{bookingID}/cancel: + post: + tags: [Catalog] + operationId: cancelDashboardBooking + security: + - bearerAuth: [] + parameters: + - in: path + name: bookingID + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Booking cancelled; freed class seats promote the waitlist + "400": + description: Booking already cancelled + "404": + description: Booking not found /v1/catalog/blocked-days: get: tags: [Catalog] diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 906599f..36d6d0f 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -309,6 +309,8 @@ const dictionaries = { "dashboard.totalBookings": "celkem rezervací", "dashboard.newBooking": "Nová rezervace", "dashboard.bookingDetails": "Detail rezervace", + "dashboard.booking.cancelBooking": "Zrušit rezervaci", + "dashboard.booking.confirmCancel": "Potvrdit zrušení?", "dashboard.customerDetails": "Detail zákazníka", "dashboard.close": "Zavřít", "dashboard.edit": "Upravit", @@ -324,6 +326,7 @@ const dictionaries = { "dashboard.nextMonth": "Další měsíc", "dashboard.confirmed": "Potvrzeno", "dashboard.pending": "Čeká", + "dashboard.waitlisted": "Čekací listina", "dashboard.cancelled": "Zrušeno", "dashboard.completed": "Dokončeno", "dashboard.welcome.title": "Vítejte v Bookra", @@ -878,6 +881,8 @@ const dictionaries = { "dashboard.totalBookings": "total bookings", "dashboard.newBooking": "New Booking", "dashboard.bookingDetails": "Booking Details", + "dashboard.booking.cancelBooking": "Cancel booking", + "dashboard.booking.confirmCancel": "Confirm cancellation?", "dashboard.customerDetails": "Customer Details", "dashboard.close": "Close", "dashboard.edit": "Edit", @@ -893,6 +898,7 @@ const dictionaries = { "dashboard.nextMonth": "Next month", "dashboard.confirmed": "Confirmed", "dashboard.pending": "Pending", + "dashboard.waitlisted": "Waitlisted", "dashboard.cancelled": "Cancelled", "dashboard.completed": "Completed", "dashboard.welcome.title": "Welcome to Bookra", diff --git a/apps/frontend/src/routes/dashboard/bookings-page.tsx b/apps/frontend/src/routes/dashboard/bookings-page.tsx index ff8d3e8..a59133d 100644 --- a/apps/frontend/src/routes/dashboard/bookings-page.tsx +++ b/apps/frontend/src/routes/dashboard/bookings-page.tsx @@ -9,7 +9,7 @@ import { DashboardLayout, useDashboardData } from "./layout"; function BookingsPage() { const data = useDashboardData(); - const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "cancelled" | "completed">("all"); + const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "waitlisted" | "cancelled" | "completed">("all"); const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all"); const [searchQuery, setSearchQuery] = createSignal(""); const [showNewBooking, setShowNewBooking] = createSignal(false); @@ -81,6 +81,7 @@ function BookingsPage() { total: bookings.length, confirmed: bookings.filter((b: any) => b.status === "confirmed").length, pending: bookings.filter((b: any) => b.status === "pending").length, + waitlisted: bookings.filter((b: any) => b.status === "waitlisted").length, cancelled: bookings.filter((b: any) => b.status === "cancelled").length, completed: bookings.filter((b: any) => b.status === "completed").length, }; @@ -111,6 +112,7 @@ function BookingsPage() { const statusLabels: Record = { confirmed: data.i18n.t("dashboard.confirmed"), pending: data.i18n.t("dashboard.pending"), + waitlisted: data.i18n.t("dashboard.waitlisted"), cancelled: data.i18n.t("dashboard.cancelled"), completed: data.i18n.t("dashboard.completed") }; @@ -130,7 +132,7 @@ function BookingsPage() {
{[ { key: "confirmed", label: data.i18n.t("dashboard.confirmed"), value: bookingStats().confirmed }, - { key: "pending", label: data.i18n.t("dashboard.pending"), value: bookingStats().pending }, + { key: "waitlisted", label: data.i18n.t("dashboard.waitlisted"), value: bookingStats().waitlisted }, { key: "cancelled", label: data.i18n.t("dashboard.cancelled"), value: bookingStats().cancelled }, { key: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed }, ].map((s) => ( @@ -151,6 +153,7 @@ function BookingsPage() { { value: "all", label: data.i18n.t("dashboard.filter.all") }, { value: "confirmed", label: data.i18n.t("dashboard.confirmed") }, { value: "pending", label: data.i18n.t("dashboard.pending") }, + { value: "waitlisted", label: data.i18n.t("dashboard.waitlisted") }, { value: "cancelled", label: data.i18n.t("dashboard.cancelled") }, { value: "completed", label: data.i18n.t("dashboard.completed") }, ]} /> @@ -218,6 +221,7 @@ function BookingsPage() { diff --git a/apps/frontend/src/routes/dashboard/dashboard-data.ts b/apps/frontend/src/routes/dashboard/dashboard-data.ts index 3a1eca2..ba1a72d 100644 --- a/apps/frontend/src/routes/dashboard/dashboard-data.ts +++ b/apps/frontend/src/routes/dashboard/dashboard-data.ts @@ -48,6 +48,7 @@ function createDemoData(i18n: ReturnType) { { id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(at(3, 10)), endsAt: fmt(at(3, 11)), reference: "BOK-006", location: salonA, staffName: staff1, notes: "" }, { id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(at(4, 9)), endsAt: fmt(at(4, 10)), reference: "BOK-008", location: salonA, staffName: staff2, notes: "" }, { id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(at(5, 11)), endsAt: fmt(at(5, 12)), reference: "BOK-009", location: salonA, staffName: staff1, notes: "" }, + { id: "b13", customerName: customers[0].name, customerEmail: customers[0].email, service: cs ? "Jóga pro začátečníky" : "Beginner yoga", status: "waitlisted", startsAt: fmt(at(3, 18)), endsAt: fmt(at(3, 19)), reference: "BOK-013", location: salonA, staffName: "", notes: "" }, ]; return { diff --git a/apps/frontend/src/routes/dashboard/layout.tsx b/apps/frontend/src/routes/dashboard/layout.tsx index 0e8d27e..fcf0ca2 100644 --- a/apps/frontend/src/routes/dashboard/layout.tsx +++ b/apps/frontend/src/routes/dashboard/layout.tsx @@ -11,6 +11,7 @@ import { UserCircleIcon, MapPinIcon, ScissorsIcon, UsersIcon } from "../../components/dashboard/icons"; import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data"; +import { apiClient } from "../../lib/api-client"; export { useDashboardData }; @@ -30,6 +31,31 @@ function DashboardShell(props: { children: JSX.Element }) { const theme = useTheme(); const location = useLocation(); const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false); + const [cancelConfirm, setCancelConfirm] = createSignal<"idle" | "armed" | "busy">("idle"); + + const handleCancelBooking = async () => { + if (cancelConfirm() === "busy") return; + if (cancelConfirm() === "idle") { setCancelConfirm("armed"); return; } + const booking = data.selectedBooking(); + const bearer = data.token(); + if (!booking) return; + if (!bearer || bearer.startsWith("demo.")) { + data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze rušit rezervace." : "Cannot cancel bookings in demo mode."); + setCancelConfirm("idle"); + return; + } + setCancelConfirm("busy"); + try { + const response = await (apiClient as any).POST(`/v1/catalog/bookings/${booking.id}/cancel`, { headers: { Authorization: `Bearer ${bearer}` } }); + if (response.error) throw new Error("cancel failed"); + data.setShowBookingDetail(false); + void data.refetchSummary(); + } catch { + data.setDemoNotice(data.i18n.locale() === "cs" ? "Zrušení rezervace selhalo." : "Failed to cancel booking."); + } finally { + setCancelConfirm("idle"); + } + }; const activeSection = () => { const p = location.pathname; @@ -242,6 +268,7 @@ function DashboardShell(props: { children: JSX.Element }) {
@@ -255,6 +282,25 @@ function DashboardShell(props: { children: JSX.Element }) {
+ +
+ +
+
diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 1f51cd6..f03edce 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -287,6 +287,22 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/catalog/bookings/{bookingID}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["cancelDashboardBooking"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/catalog/class-sessions": { parameters: { query?: never; @@ -2030,6 +2046,40 @@ export interface operations { }; }; }; + cancelDashboardBooking: { + parameters: { + query?: never; + header?: never; + path: { + bookingID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Booking cancelled; freed class seats promote the waitlist */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Booking already cancelled */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Booking not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listClassSessions: { parameters: { query?: never; diff --git a/project.md b/project.md index 458b2cf..cf5ace0 100644 --- a/project.md +++ b/project.md @@ -75,6 +75,13 @@ - dashboard zones page gained a staff-scope selector on the working-hours tab (business-wide vs individual schedule) and a Classes tab with template create/delete and session scheduling - normalized `availability_rules` time columns (`HH:MM:SS`) down to the `HH:MM` API contract in the service layer - remaining: live staging validation +- `[done/partial]` Milestone 11: Booking lifecycle — dashboard cancellation and waitlist promotion + - businesses can cancel bookings from the dashboard (`POST /v1/catalog/bookings/{id}/cancel`), tenant-checked, with a two-step confirm in the booking detail modal + - cancelling a class booking (dashboard or customer self-service) now promotes the earliest waitlisted bookings while free capacity remains, ordered by booking `created_at`; promoted customers receive a confirmation email, cancelled customers a cancellation email + - shared `bookings.PromoteWaitlist` helper drives both cancel paths; waitlist ledger entries are marked resolved on promotion + - bookings surface the `waitlisted` status: stats card, filter option, warning-tone badge, demo seed entry; `dashboard.{status}` interpolation resolves the new i18n key automatically + - `BookingRecord` now carries `created_at` (needed for fair waitlist ordering); `GetBookingByID` added to the repository surface + - remaining: live staging validation; waitlist position display and manual promotion controls are not yet exposed ## 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. From 0dc26e0c16ce503d4c21cfedc77a3f0d8b2e24d2 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 11:34:00 +0200 Subject: [PATCH 05/13] feat(dashboard): real business metrics with week-over-week trends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard KPIs were three flat counters with no trend data — the cards rendered a neutral dash where the design intends movement indicators. Summary now queries three trailing windows and emits four KPIs with real deltas: bookings this week, completed (new CompletedCount metric in both repositories), cancellations, and utilization in percentage points. KPI labels localize in the frontend via a code-to-key map so Czech operators no longer see English labels on live data. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/internal/bookings/service.go | 32 ++++++++- .../backend/internal/bookings/service_test.go | 68 +++++++++++++++++++ apps/backend/internal/db/bookings.go | 4 +- apps/backend/internal/db/repository.go | 4 ++ apps/backend/internal/domain/models.go | 8 ++- apps/backend/openapi/bookra.openapi.yaml | 5 ++ apps/frontend/src/providers/i18n-provider.tsx | 6 ++ .../src/routes/dashboard/dashboard-data.ts | 11 ++- packages/api-client/src/generated/types.ts | 3 + project.md | 8 +++ 10 files changed, 141 insertions(+), 8 deletions(-) diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index f6e0edb..11d0501 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -569,6 +569,14 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip if err != nil { return domain.DashboardSummary{}, err } + previousMetrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now.AddDate(0, 0, -7), now) + if err != nil { + return domain.DashboardSummary{}, err + } + priorMetrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now.AddDate(0, 0, -14), now.AddDate(0, 0, -7)) + if err != nil { + return domain.DashboardSummary{}, err + } upcomingRecords, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, now, weekEnd) if err != nil { return domain.DashboardSummary{}, err @@ -614,9 +622,10 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip PublicBookingURL: "/book/" + membership.Tenant.Slug, SetupCompletion: setupCompletion(brandName, len(locationNames), len(serviceNames), len(staffNames), scheduleCount), KPIs: []domain.DashboardKPI{ - {Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)}, - {Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)}, - {Code: "utilization", Label: "Utilization", Value: fmt.Sprintf("%d%%", metrics.UtilizationPercent)}, + dashboardKPI("bookings_this_week", "Bookings this week", metrics.BookingsCount, previousMetrics.BookingsCount, ""), + dashboardKPI("completed", "Completed", previousMetrics.CompletedCount, priorMetrics.CompletedCount, ""), + dashboardKPI("cancellations", "Cancellations", metrics.CancellationsCount, previousMetrics.CancellationsCount, ""), + dashboardKPI("utilization", "Utilization", metrics.UtilizationPercent, previousMetrics.UtilizationPercent, "%"), }, UpcomingBookings: upcoming, AllBookings: allBookings, @@ -909,3 +918,20 @@ func reminderSchedule(startsAt time.Time) (time.Time, bool) { return time.Time{}, false } } + +func dashboardKPI(code, label string, current int, previous int, suffix string) domain.DashboardKPI { + delta := current - previous + change := "0" + trend := "neutral" + if delta > 0 { + change = fmt.Sprintf("+%d", delta) + trend = "up" + } else if delta < 0 { + change = fmt.Sprintf("%d", delta) + trend = "down" + } + if suffix != "" && change != "0" { + change += suffix + } + return domain.DashboardKPI{Code: code, Label: label, Value: fmt.Sprintf("%d%s", current, suffix), Change: change, Trend: trend} +} diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index e74ff19..d250946 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -450,3 +450,71 @@ func TestCustomerCancelPromotesWaitlist(t *testing.T) { t.Fatalf("expected waiter promoted to confirmed, got %q", booking.Status) } } + +func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + ctx := context.Background() + + membership, err := repo.GetTenantMembershipBySubject(ctx, "demo-owner") + if err != nil { + t.Fatalf("membership: %v", err) + } + tenantID := membership.Tenant.ID + + now := time.Now().UTC() + seed := func(offsetDays int, hours int, status string) { + t.Helper() + starts := now.AddDate(0, 0, offsetDays).Add(time.Duration(hours) * time.Hour) + _, err := repo.CreateBooking(ctx, db.CreateBookingParams{ + TenantID: tenantID, + BookingMode: "appointment", + StartsAt: starts, + EndsAt: starts.Add(time.Hour), + Status: status, + Reference: "BK-T", + }) + if err != nil { + t.Fatalf("seed booking: %v", err) + } + } + + // Next 7 days: 3 bookings, one cancelled. + seed(1, 0, "confirmed") + seed(2, 0, "confirmed") + seed(3, 0, "cancelled") + // Last 7 days: 1 confirmed booking that already ended. + seed(-2, 0, "confirmed") + // 7-14 days ago: 2 completed bookings. + seed(-8, 0, "confirmed") + seed(-9, 0, "confirmed") + + summary, err := service.DashboardSummary(ctx, domain.Principal{Subject: "demo-owner", Role: "owner"}) + if err != nil { + t.Fatalf("dashboard summary: %v", err) + } + if len(summary.KPIs) != 4 { + t.Fatalf("expected 4 kpis, got %d", len(summary.KPIs)) + } + byCode := map[string]domain.DashboardKPI{} + for _, kpi := range summary.KPIs { + byCode[kpi.Code] = kpi + } + + bookings := byCode["bookings_this_week"] + if bookings.Value != "3" || bookings.Change != "+2" || bookings.Trend != "up" { + t.Fatalf("bookings kpi = %+v", bookings) + } + completed := byCode["completed"] + if completed.Value != "1" || completed.Change != "-1" || completed.Trend != "down" { + t.Fatalf("completed kpi = %+v", completed) + } + cancellations := byCode["cancellations"] + if cancellations.Value != "1" || cancellations.Change != "+1" || cancellations.Trend != "up" { + t.Fatalf("cancellations kpi = %+v", cancellations) + } + utilization := byCode["utilization"] + if utilization.Value != "66%" || utilization.Trend != "down" { + t.Fatalf("utilization kpi = %+v", utilization) + } +} diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 1e0d92f..d335755 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -179,13 +179,15 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, / NULLIF(COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3), 0) ), 0 - )::integer AS utilization_percent + )::integer AS utilization_percent, + COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed' AND ends_at < now()) AS completed_count FROM bookings WHERE tenant_id = $1 `, tenantID, startsAt, endsAt).Scan( &metrics.BookingsCount, &metrics.CancellationsCount, &metrics.UtilizationPercent, + &metrics.CompletedCount, ) return metrics, err } diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 9cde1d2..af82903 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -394,6 +394,7 @@ type DashboardMetrics struct { BookingsCount int CancellationsCount int UtilizationPercent int + CompletedCount int } type ReminderJobParams struct { @@ -1452,6 +1453,9 @@ func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID strin } if booking.Status == "confirmed" { metrics.UtilizationPercent++ + if booking.EndsAt.Before(time.Now().UTC()) { + metrics.CompletedCount++ + } } } if metrics.BookingsCount == 0 { diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 245a0b3..a5f08a1 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -10,9 +10,11 @@ type Principal struct { } type DashboardKPI struct { - Code string `json:"code"` - Label string `json:"label"` - Value string `json:"value"` + Code string `json:"code"` + Label string `json:"label"` + Value string `json:"value"` + Change string `json:"change,omitempty"` + Trend string `json:"trend,omitempty"` } type UpcomingBooking struct { diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 3fb2345..a47e7be 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -1624,6 +1624,11 @@ components: type: string value: type: string + change: + type: string + trend: + type: string + enum: [up, down, neutral] DashboardSummary: type: object required: [tenantName, tenantSlug, locale, timezone, planCode, publicBookingUrl, setupCompletion, kpis] diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 36d6d0f..61cbb0a 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -295,6 +295,9 @@ const dictionaries = { "dashboard.kpi.bookings": "Celkem rezervací", "dashboard.kpi.cancelled": "Zrušených", "dashboard.kpi.completed": "Dokončených", + "dashboard.kpi.bookingsThisWeek": "Rezervace tento týden", + "dashboard.kpi.cancellations": "Zrušení", + "dashboard.kpi.utilization": "Vytížení", "dashboard.kpi.newClients": "Noví klienti", "dashboard.recentActivity": "Nedávná aktivita", "dashboard.upcomingBookings": "Nadcházející rezervace", @@ -867,6 +870,9 @@ const dictionaries = { "dashboard.kpi.bookings": "Total Bookings", "dashboard.kpi.cancelled": "Cancelled", "dashboard.kpi.completed": "Completed", + "dashboard.kpi.bookingsThisWeek": "Bookings this week", + "dashboard.kpi.cancellations": "Cancellations", + "dashboard.kpi.utilization": "Utilization", "dashboard.kpi.newClients": "New Clients", "dashboard.recentActivity": "Recent Activity", "dashboard.upcomingBookings": "Upcoming Bookings", diff --git a/apps/frontend/src/routes/dashboard/dashboard-data.ts b/apps/frontend/src/routes/dashboard/dashboard-data.ts index ba1a72d..64faf51 100644 --- a/apps/frontend/src/routes/dashboard/dashboard-data.ts +++ b/apps/frontend/src/routes/dashboard/dashboard-data.ts @@ -211,8 +211,17 @@ export function DashboardDataProvider(props: { children: any }) { }; }; + const kpiLabelKeys: Record = { + bookings_this_week: "dashboard.kpi.bookingsThisWeek", + completed: "dashboard.kpi.completed", + cancellations: "dashboard.kpi.cancellations", + utilization: "dashboard.kpi.utilization", + }; const normalizedKpis = createMemo(() => (resolvedSummary()?.kpis ?? []).map((kpi: any) => ({ - ...kpi, trend: kpi.trend ?? "neutral", change: kpi.change ?? "—", + ...kpi, + label: kpi.code && kpiLabelKeys[kpi.code] ? i18n.t(kpiLabelKeys[kpi.code]) : kpi.label, + trend: kpi.trend ?? "neutral", + change: kpi.change ?? "—", }))); const normalizedUpcomingBookings = createMemo(() => (resolvedSummary()?.upcomingBookings ?? []).map((booking: any) => ({ diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index f03edce..34275eb 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -1101,8 +1101,11 @@ export interface components { startsAt: string; }; DashboardKPI: { + change?: string; code: string; label: string; + /** @enum {string} */ + trend?: "up" | "down" | "neutral"; value: string; }; DashboardSummary: { diff --git a/project.md b/project.md index cf5ace0..467faff 100644 --- a/project.md +++ b/project.md @@ -82,6 +82,14 @@ - bookings surface the `waitlisted` status: stats card, filter option, warning-tone badge, demo seed entry; `dashboard.{status}` interpolation resolves the new i18n key automatically - `BookingRecord` now carries `created_at` (needed for fair waitlist ordering); `GetBookingByID` added to the repository surface - remaining: live staging validation; waitlist position display and manual promotion controls are not yet exposed +- `[done/partial]` Milestone 12: Real dashboard business metrics + - `DashboardMetrics` gained `CompletedCount` (confirmed bookings already ended within the window) in both pgx and in-memory repositories + - `DashboardSummary` now queries three trailing windows (next 7d, last 7d, 7-14d ago) and emits four KPIs with real trend deltas: `bookings_this_week`, `completed`, `cancellations`, `utilization` (delta in percentage points) + - `DashboardKPI` carries optional `change`/`trend` (`up|down|neutral`); the KPI card already rendered these, so real data now drives the trend arrows instead of the previous `neutral`/`—` fallback + - KPI labels are localized in the frontend via a `code` → i18n-key map (`dashboard.kpi.bookingsThisWeek`, `cancellations`, `utilization`; `completed` reuses the existing key); backend labels stay English as a fallback + - the booking-trend chart was already bucketing real `allBookings` into the current week — confirmed, no change needed + - demo mode intentionally keeps curated static KPI values; real tenants get the computed set + - remaining: live staging validation; "new clients" KPI still has no backend metric (customer entity lacks first-booking tracking) ## 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. From cdad7875e1bdd1c640ba9192c22ef7cfca58a1d1 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 12:47:07 +0200 Subject: [PATCH 06/13] fix(reminders): lifecycle-aware jobs and dispatch skip guard The reminder pipeline fired regardless of what happened to the booking after creation: cancelled customers got reminded about bookings that weren't happening, reschedules kept the old reminder time, and waitlisted bookings armed jobs too. Pending jobs are now cancelled on both cancel paths, deleted and re-armed on reschedule, and dispatch joins bookings.status to skip anything not confirmed. Reschedule also gained the same availability-window and blocked-day validation as booking creation, closing a 3am-reschedule hole via the manage URL. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal/bookings/customer_service.go | 26 ++++ apps/backend/internal/bookings/service.go | 12 +- .../backend/internal/bookings/service_test.go | 122 ++++++++++++++++++ apps/backend/internal/catalog/service.go | 1 + apps/backend/internal/db/bookings.go | 20 ++- apps/backend/internal/db/repository.go | 30 +++++ apps/backend/internal/domain/models.go | 1 + .../backend/internal/notifications/service.go | 8 ++ .../internal/notifications/service_test.go | 50 ++++++- apps/backend/openapi/bookra.openapi.yaml | 4 +- packages/api-client/src/generated/types.ts | 1 + project.md | 6 + 12 files changed, 272 insertions(+), 9 deletions(-) diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index b587709..506b219 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -168,10 +168,35 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin } } + // Appointments must land inside bookable hours and off blocked days; + // class bookings keep their session-defined window. + if booking.ClassSessionID == nil { + tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID) + if err != nil { + return err + } + if err := validateAppointmentTarget(ctx, s.repo, tenant, booking.StaffID, booking.LocationID, newStartsAt, newEndsAt); err != nil { + return ErrInvalidReschedule + } + } + if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil { return err } + // Re-arm the reminder for the new time; a booking moved inside the + // no-reminder horizon simply gets none. + if err := s.repo.DeletePendingReminderJobsForBooking(ctx, booking.ID); err == nil { + if scheduledFor, ok := reminderSchedule(newStartsAt); ok { + _ = s.repo.CreateReminderJob(ctx, db.ReminderJobParams{ + TenantID: booking.TenantID, + BookingID: booking.ID, + Channel: "email", + ScheduledFor: scheduledFor, + }) + } + } + // Send reschedule email (async) go s.sendRescheduleEmail(booking, newStartsAt, newEndsAt) @@ -195,6 +220,7 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil { return err } + _ = s.repo.CancelPendingReminderJobsForBooking(ctx, booking.ID) // Send cancellation email (async) go s.sendCancellationEmail(booking) diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 11d0501..06b1f2d 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -290,7 +290,7 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques if !startsAt.Add(expectedDuration).Equal(endsAt) { return domain.CreateBookingResponse{}, fmt.Errorf("%w: appointment duration must match service duration", ErrInvalidBooking) } - if err := s.validateAppointmentTarget(ctx, tenant, request.StaffID, request.LocationID, startsAt, endsAt); err != nil { + if err := validateAppointmentTarget(ctx, s.repo, tenant, request.StaffID, request.LocationID, startsAt, endsAt); err != nil { return domain.CreateBookingResponse{}, err } for _, booking := range existing { @@ -383,9 +383,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques // resources owned by the tenant and lands inside bookable time: the staff and // location must belong to the tenant, the window must fit an availability rule // the booking could claim, and it must not overlap a blocked day. -func (s *Service) validateAppointmentTarget(ctx context.Context, tenant db.TenantRecord, staffID, locationID *string, startsAt, endsAt time.Time) error { +func validateAppointmentTarget(ctx context.Context, repo db.Repository, tenant db.TenantRecord, staffID, locationID *string, startsAt, endsAt time.Time) error { if staffID != nil { - staff, err := s.repo.GetStaffByID(ctx, *staffID) + staff, err := repo.GetStaffByID(ctx, *staffID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return fmt.Errorf("%w: staffId is not available for tenant", ErrInvalidBooking) @@ -397,7 +397,7 @@ func (s *Service) validateAppointmentTarget(ctx context.Context, tenant db.Tenan } } if locationID != nil { - location, err := s.repo.GetLocationByID(ctx, *locationID) + location, err := repo.GetLocationByID(ctx, *locationID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return fmt.Errorf("%w: locationId is not available for tenant", ErrInvalidBooking) @@ -409,11 +409,11 @@ func (s *Service) validateAppointmentTarget(ctx context.Context, tenant db.Tenan } } - rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenant.ID) + rules, err := repo.ListAvailabilityRulesByTenant(ctx, tenant.ID) if err != nil { return err } - blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, tenant.ID, startsAt.UTC(), endsAt.UTC()) + blockedDays, err := repo.ListBlockedDaysByTenant(ctx, tenant.ID, startsAt.UTC(), endsAt.UTC()) if err != nil { return err } diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index d250946..40ff770 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -518,3 +518,125 @@ func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) { t.Fatalf("utilization kpi = %+v", utilization) } } + +func bookAppointmentSlot(t *testing.T, repo db.Repository, service *Service) domain.CreateBookingResponse { + t.Helper() + // Target a day ~72h out so the booking lands in the -24h reminder horizon. + target := time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02") + availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: target}) + if err != nil { + t.Fatalf("availability: %v", err) + } + var slot domain.TimeSlot + for _, s := range availability.Slots { + if s.Mode == "appointment" { + slot = s + break + } + } + if slot.StartsAt == "" { + t.Fatal("expected appointment slot") + } + resp, err := service.Create(context.Background(), domain.CreateBookingRequest{ + TenantSlug: "studio-atelier", + BookingMode: "appointment", + ServiceID: slot.ServiceID, + StaffID: slot.StaffID, + LocationID: slot.LocationID, + CustomerName: "Reminder Test", + CustomerEmail: "reminder-test@example.com", + StartsAt: slot.StartsAt, + EndsAt: slot.EndsAt, + }) + if err != nil { + t.Fatalf("create booking: %v", err) + } + return resp +} + +func pendingJobsFor(t *testing.T, repo db.Repository, bookingID string) []db.ReminderJobRecord { + t.Helper() + jobs, err := repo.ListDueReminderJobs(context.Background(), time.Now().UTC().AddDate(1, 0, 0), 100) + if err != nil { + t.Fatalf("list due jobs: %v", err) + } + var out []db.ReminderJobRecord + for _, job := range jobs { + if job.BookingID == bookingID { + out = append(out, job) + } + } + return out +} + +func TestCancelBookingClearsPendingReminder(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + customerService := NewCustomerService(repo, nil, "http://localhost:3000") + ctx := context.Background() + + resp := bookAppointmentSlot(t, repo, service) + if len(pendingJobsFor(t, repo, resp.BookingID)) != 1 { + t.Fatal("expected one pending reminder job after booking") + } + + manageURL, _ := url.Parse(resp.ManageURL) + token := manageURL.Query().Get("token") + if err := customerService.CancelBooking(ctx, resp.Reference, token); err != nil { + t.Fatalf("cancel: %v", err) + } + if got := pendingJobsFor(t, repo, resp.BookingID); len(got) != 0 { + t.Fatalf("expected no pending jobs after cancel, got %d", len(got)) + } +} + +func TestRescheduleRearmsReminder(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + customerService := NewCustomerService(repo, nil, "http://localhost:3000") + ctx := context.Background() + + resp := bookAppointmentSlot(t, repo, service) + booking, _ := repo.GetBookingByID(ctx, resp.BookingID) + + newStartsAt := booking.StartsAt.Add(48 * time.Hour) + newEndsAt := booking.EndsAt.Add(48 * time.Hour) + manageURL, _ := url.Parse(resp.ManageURL) + token := manageURL.Query().Get("token") + if err := customerService.RescheduleBooking(ctx, resp.Reference, domain.RescheduleBookingRequest{ + NewStartsAt: newStartsAt.UTC().Format(time.RFC3339), + NewEndsAt: newEndsAt.UTC().Format(time.RFC3339), + }, token); err != nil { + t.Fatalf("reschedule: %v", err) + } + + jobs := pendingJobsFor(t, repo, resp.BookingID) + if len(jobs) != 1 { + t.Fatalf("expected one pending job after reschedule, got %d", len(jobs)) + } + expected := newStartsAt.Add(-24 * time.Hour) + if !jobs[0].ScheduledFor.Equal(expected) { + t.Fatalf("expected reminder at %v, got %v", expected, jobs[0].ScheduledFor) + } +} + +func TestRescheduleRejectsTimeOutsideAvailabilityWindow(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + customerService := NewCustomerService(repo, nil, "http://localhost:3000") + ctx := context.Background() + + resp := bookAppointmentSlot(t, repo, service) + manageURL, _ := url.Parse(resp.ManageURL) + token := manageURL.Query().Get("token") + + // 03:00 UTC next week is outside every availability rule. + night := time.Now().UTC().AddDate(0, 0, 7).Truncate(24 * time.Hour).Add(3 * time.Hour) + err := customerService.RescheduleBooking(ctx, resp.Reference, domain.RescheduleBookingRequest{ + NewStartsAt: night.UTC().Format(time.RFC3339), + NewEndsAt: night.Add(time.Hour).UTC().Format(time.RFC3339), + }, token) + if !errors.Is(err, ErrInvalidReschedule) { + t.Fatalf("expected ErrInvalidReschedule, got %v", err) + } +} diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index eccdef8..25684f4 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -1298,6 +1298,7 @@ func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal, if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil { return err } + _ = s.repo.CancelPendingReminderJobsForBooking(ctx, booking.ID) go s.sendBookingEmail(booking, false) diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index d335755..30d137a 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -108,7 +108,7 @@ func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.T rows, err := r.pool.Query(ctx, ` SELECT rj.id, rj.tenant_id, t.name, t.locale, t.timezone, rj.booking_id, rj.channel, rj.scheduled_for, - b.customer_name, b.customer_email, b.reference, b.starts_at, rj.status + b.customer_name, b.customer_email, b.reference, b.starts_at, rj.status, b.status FROM reminder_jobs rj INNER JOIN bookings b ON b.id = rj.booking_id INNER JOIN tenants t ON t.id = rj.tenant_id @@ -138,6 +138,7 @@ func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.T &record.Reference, &record.StartsAt, &record.Status, + &record.BookingStatus, ); err != nil { return nil, err } @@ -191,3 +192,20 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, ) return metrics, err } + +func (r *PGRepository) CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE reminder_jobs + SET status = 'cancelled' + WHERE booking_id = $1 AND status = 'pending' + `, bookingID) + return err +} + +func (r *PGRepository) DeletePendingReminderJobsForBooking(ctx context.Context, bookingID string) error { + _, err := r.pool.Exec(ctx, ` + DELETE FROM reminder_jobs + WHERE booking_id = $1 AND status = 'pending' + `, bookingID) + return err +} diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index af82903..1d5e4c8 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -40,6 +40,8 @@ type Repository interface { CreateReminderJob(ctx context.Context, params ReminderJobParams) error ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error + CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error + DeletePendingReminderJobsForBooking(ctx context.Context, bookingID string) error CreateNotificationDeliveryLog(ctx context.Context, params NotificationDeliveryLogParams) error GetDashboardMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) (DashboardMetrics, error) GetSubscriptionSnapshot(ctx context.Context, tenantID string) (BillingSnapshotRecord, error) @@ -418,6 +420,7 @@ type ReminderJobRecord struct { Reference string StartsAt time.Time Status string + BookingStatus string } type NotificationDeliveryLogParams struct { @@ -1413,6 +1416,12 @@ func (r *MemoryRepository) ListDueReminderJobs(_ context.Context, dueBefore time var out []ReminderJobRecord for _, job := range r.reminderJobs { if job.Status == "pending" && !job.ScheduledFor.After(dueBefore) { + for _, booking := range r.bookings { + if booking.ID == job.BookingID { + job.BookingStatus = booking.Status + break + } + } out = append(out, job) } } @@ -1433,6 +1442,27 @@ func (r *MemoryRepository) MarkReminderJobDispatched(_ context.Context, reminder return pgx.ErrNoRows } +func (r *MemoryRepository) CancelPendingReminderJobsForBooking(_ context.Context, bookingID string) error { + for index := range r.reminderJobs { + if r.reminderJobs[index].BookingID == bookingID && r.reminderJobs[index].Status == "pending" { + r.reminderJobs[index].Status = "cancelled" + } + } + return nil +} + +func (r *MemoryRepository) DeletePendingReminderJobsForBooking(_ context.Context, bookingID string) error { + kept := r.reminderJobs[:0] + for _, job := range r.reminderJobs { + if job.BookingID == bookingID && job.Status == "pending" { + continue + } + kept = append(kept, job) + } + r.reminderJobs = kept + return nil +} + func (r *MemoryRepository) CreateNotificationDeliveryLog(_ context.Context, params NotificationDeliveryLogParams) error { r.deliveryLogs = append(r.deliveryLogs, params) return nil diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index a5f08a1..7947332 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -251,6 +251,7 @@ type DispatchReminderJobsResponse struct { ProcessedCount int `json:"processedCount"` SentCount int `json:"sentCount"` FailedCount int `json:"failedCount"` + SkippedCount int `json:"skippedCount"` } // ============================================ diff --git a/apps/backend/internal/notifications/service.go b/apps/backend/internal/notifications/service.go index f033ce2..b8298cd 100644 --- a/apps/backend/internal/notifications/service.go +++ b/apps/backend/internal/notifications/service.go @@ -68,6 +68,14 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe for _, job := range jobs { response.ProcessedCount++ + if job.BookingStatus != "confirmed" { + if err := s.repo.MarkReminderJobDispatched(ctx, job.ID, "skipped", s.now()); err != nil { + return domain.DispatchReminderJobsResponse{}, err + } + response.SkippedCount++ + continue + } + status := "sent" provider := "unknown" externalID := "" diff --git a/apps/backend/internal/notifications/service_test.go b/apps/backend/internal/notifications/service_test.go index 4178c13..4676713 100644 --- a/apps/backend/internal/notifications/service_test.go +++ b/apps/backend/internal/notifications/service_test.go @@ -65,9 +65,21 @@ func TestDispatchDueProcessesPendingEmailReminders(t *testing.T) { func TestDispatchDueFailsUnknownChannel(t *testing.T) { repo := db.NewMemoryRepository() + startsAt := time.Now().UTC().Add(26 * time.Hour) + created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{ + TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", + BookingMode: "appointment", + StartsAt: startsAt, + EndsAt: startsAt.Add(time.Hour), + Status: "confirmed", + Reference: "BK-TEST-PUSH", + }) + if err != nil { + t.Fatalf("create booking: %v", err) + } if err := repo.CreateReminderJob(context.Background(), db.ReminderJobParams{ TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", - BookingID: "booking-unknown-channel", + BookingID: created.ID, Channel: "push", ScheduledFor: time.Now().UTC().Add(-time.Minute), }); err != nil { @@ -90,3 +102,39 @@ func TestDispatchRequestContractShape(t *testing.T) { t.Fatalf("expected limit 20, got %d", request.Limit) } } + +func TestDispatchDueSkipsNonConfirmedBooking(t *testing.T) { + repo := db.NewMemoryRepository() + startsAt := time.Now().UTC().Add(26 * time.Hour) + created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{ + TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", + BookingMode: "appointment", + StartsAt: startsAt, + EndsAt: startsAt.Add(time.Hour), + Status: "confirmed", + Reference: "BK-TEST-SKIP", + }) + if err != nil { + t.Fatalf("create booking: %v", err) + } + if err := repo.CreateReminderJob(context.Background(), db.ReminderJobParams{ + TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", + BookingID: created.ID, + Channel: "email", + ScheduledFor: time.Now().UTC().Add(-time.Minute), + }); err != nil { + t.Fatalf("create reminder job: %v", err) + } + if err := repo.UpdateBookingStatus(context.Background(), created.ID, "cancelled"); err != nil { + t.Fatalf("cancel booking: %v", err) + } + + service := NewService(config.Config{Environment: "development"}, repo) + response, err := service.DispatchDue(context.Background(), 10) + if err != nil { + t.Fatalf("dispatch due: %v", err) + } + if response.ProcessedCount != 1 || response.SkippedCount != 1 || response.SentCount != 0 { + t.Fatalf("expected 1 skipped, got %+v", response) + } +} diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index a47e7be..2866f3c 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -2291,7 +2291,7 @@ components: maximum: 200 DispatchReminderJobsResponse: type: object - required: [processedCount, sentCount, failedCount] + required: [processedCount, sentCount, failedCount, skippedCount] properties: processedCount: type: integer @@ -2299,3 +2299,5 @@ components: type: integer failedCount: type: integer + skippedCount: + type: integer diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 34275eb..b74e3ea 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -1128,6 +1128,7 @@ export interface components { failedCount: number; processedCount: number; sentCount: number; + skippedCount: number; }; EmailTemplate: { bodyText?: string; diff --git a/project.md b/project.md index 467faff..475fcd0 100644 --- a/project.md +++ b/project.md @@ -90,6 +90,12 @@ - the booking-trend chart was already bucketing real `allBookings` into the current week — confirmed, no change needed - demo mode intentionally keeps curated static KPI values; real tenants get the computed set - remaining: live staging validation; "new clients" KPI still has no backend metric (customer entity lacks first-booking tracking) +- `[done/partial]` Milestone 13: Reminder pipeline reliability + - reminder jobs are lifecycle-aware: customer and dashboard cancellation mark pending jobs `cancelled`; reschedule deletes pending jobs and re-arms one via `reminderSchedule` for the new time (no job when moved inside the no-reminder horizon) + - dispatch now joins `bookings.status` and skips non-confirmed bookings (`skipped` job status, `skippedCount` in the response contract) — cancelled/waitlisted customers no longer receive reminders for bookings that are not happening + - reschedule hardened with the same validation as booking creation: appointment reschedules must fit an availability rule and avoid blocked days (`validateAppointmentTarget` is now a shared package-level helper); class bookings keep session-defined times + - new repository methods `CancelPendingReminderJobsForBooking` / `DeletePendingReminderJobsForBooking` in pgx and in-memory implementations + - remaining: live staging validation; SMS reminder channel exists in schema (`channel` column) but only `email` is dispatched — SMS reminders are a separate opt-in feature ## 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. From c7172544b8ef090df2a927875876c5a9ee9959ae Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 12:59:44 +0200 Subject: [PATCH 07/13] feat(metrics): new-clients KPI and scheduled reminder dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard promised a new-clients card the API never produced; the metric now counts distinct customer emails whose earliest-ever booking lands in the window, joining the other four KPIs with the same trend comparison. The card grid rebalances for five items. Reminder dispatch also had no trigger — the endpoint existed but nothing called it. A scheduled GitHub Actions workflow now posts to it every 15 minutes using the job-runner key, so reminders actually fire once the two secrets are configured. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/reminder-dispatch.yml | 32 ++++++++++++++ apps/backend/internal/bookings/service.go | 1 + .../backend/internal/bookings/service_test.go | 44 +++++++++++-------- apps/backend/internal/db/bookings.go | 12 ++++- apps/backend/internal/db/repository.go | 16 +++++++ .../src/routes/dashboard/dashboard-data.ts | 1 + .../src/routes/dashboard/overview-page.tsx | 2 +- project.md | 5 +++ 8 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/reminder-dispatch.yml diff --git a/.github/workflows/reminder-dispatch.yml b/.github/workflows/reminder-dispatch.yml new file mode 100644 index 0000000..750f8fb --- /dev/null +++ b/.github/workflows/reminder-dispatch.yml @@ -0,0 +1,32 @@ +name: Reminder Dispatch + +on: + schedule: + # Every 15 minutes. GitHub scheduled workflows may run a few minutes late; + # reminder jobs are timestamp-based so lateness only delays, never skips. + - cron: "*/15 * * * *" + workflow_dispatch: + +permissions: {} + +jobs: + dispatch: + name: Dispatch due reminders + runs-on: ubuntu-latest + steps: + - name: Call dispatch endpoint + env: + API_URL: ${{ secrets.BOOKRA_API_URL }} + JOB_KEY: ${{ secrets.BOOKRA_JOB_RUNNER_KEY }} + run: | + if [ -z "$API_URL" ] || [ -z "$JOB_KEY" ]; then + echo "BOOKRA_API_URL / BOOKRA_JOB_RUNNER_KEY secrets not configured — skipping." + exit 0 + fi + status=$(curl -sS -o /tmp/dispatch.json -w "%{http_code}" \ + -X POST "$API_URL/v1/internal/jobs/reminders/dispatch" \ + -H "Content-Type: application/json" \ + -H "X-Bookra-Job-Key: $JOB_KEY" \ + -d '{"limit": 100}') + cat /tmp/dispatch.json + test "$status" = "200" diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 06b1f2d..6e32cfb 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -626,6 +626,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip dashboardKPI("completed", "Completed", previousMetrics.CompletedCount, priorMetrics.CompletedCount, ""), dashboardKPI("cancellations", "Cancellations", metrics.CancellationsCount, previousMetrics.CancellationsCount, ""), dashboardKPI("utilization", "Utilization", metrics.UtilizationPercent, previousMetrics.UtilizationPercent, "%"), + dashboardKPI("new_clients", "New clients", metrics.NewClientsCount, previousMetrics.NewClientsCount, ""), }, UpcomingBookings: upcoming, AllBookings: allBookings, diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index 40ff770..2e38fbd 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -463,38 +463,40 @@ func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) { tenantID := membership.Tenant.ID now := time.Now().UTC() - seed := func(offsetDays int, hours int, status string) { + seed := func(offsetDays int, hours int, status, email string) { t.Helper() starts := now.AddDate(0, 0, offsetDays).Add(time.Duration(hours) * time.Hour) _, err := repo.CreateBooking(ctx, db.CreateBookingParams{ - TenantID: tenantID, - BookingMode: "appointment", - StartsAt: starts, - EndsAt: starts.Add(time.Hour), - Status: status, - Reference: "BK-T", + TenantID: tenantID, + BookingMode: "appointment", + CustomerEmail: email, + StartsAt: starts, + EndsAt: starts.Add(time.Hour), + Status: status, + Reference: "BK-T", }) if err != nil { t.Fatalf("seed booking: %v", err) } } - // Next 7 days: 3 bookings, one cancelled. - seed(1, 0, "confirmed") - seed(2, 0, "confirmed") - seed(3, 0, "cancelled") - // Last 7 days: 1 confirmed booking that already ended. - seed(-2, 0, "confirmed") - // 7-14 days ago: 2 completed bookings. - seed(-8, 0, "confirmed") - seed(-9, 0, "confirmed") + // Next 7 days: 3 bookings, one cancelled; two first-time clients + // (b@ returns, having booked 9 days ago). + seed(1, 0, "confirmed", "a@example.com") + seed(2, 0, "confirmed", "b@example.com") + seed(3, 0, "cancelled", "c@example.com") + // Last 7 days: 1 confirmed booking that already ended (returning client). + seed(-2, 0, "confirmed", "d@example.com") + // 7-14 days ago: 2 completed bookings; b@'s first visit plus a new client. + seed(-8, 0, "confirmed", "b@example.com") + seed(-9, 0, "confirmed", "e@example.com") summary, err := service.DashboardSummary(ctx, domain.Principal{Subject: "demo-owner", Role: "owner"}) if err != nil { t.Fatalf("dashboard summary: %v", err) } - if len(summary.KPIs) != 4 { - t.Fatalf("expected 4 kpis, got %d", len(summary.KPIs)) + if len(summary.KPIs) != 5 { + t.Fatalf("expected 5 kpis, got %d", len(summary.KPIs)) } byCode := map[string]domain.DashboardKPI{} for _, kpi := range summary.KPIs { @@ -517,6 +519,12 @@ func TestDashboardSummaryComputesKPIsWithTrends(t *testing.T) { if utilization.Value != "66%" || utilization.Trend != "down" { t.Fatalf("utilization kpi = %+v", utilization) } + // a@ and c@ have their first-ever booking next week (2); d@ was the + // only first-timer last week (1) — b@ first booked 8 days ago. + newClients := byCode["new_clients"] + if newClients.Value != "2" || newClients.Change != "+1" || newClients.Trend != "up" { + t.Fatalf("new clients kpi = %+v", newClients) + } } func bookAppointmentSlot(t *testing.T, repo db.Repository, service *Service) domain.CreateBookingResponse { diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 30d137a..163da14 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -181,7 +181,16 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, ), 0 )::integer AS utilization_percent, - COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed' AND ends_at < now()) AS completed_count + COUNT(*) FILTER (WHERE starts_at >= $2 AND starts_at < $3 AND status = 'confirmed' AND ends_at < now()) AS completed_count, + COUNT(DISTINCT customer_email) FILTER ( + WHERE starts_at >= $2 AND starts_at < $3 + AND NOT EXISTS ( + SELECT 1 FROM bookings earlier + WHERE earlier.tenant_id = bookings.tenant_id + AND earlier.customer_email = bookings.customer_email + AND earlier.starts_at < $2 + ) + ) AS new_clients_count FROM bookings WHERE tenant_id = $1 `, tenantID, startsAt, endsAt).Scan( @@ -189,6 +198,7 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, &metrics.CancellationsCount, &metrics.UtilizationPercent, &metrics.CompletedCount, + &metrics.NewClientsCount, ) return metrics, err } diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 1d5e4c8..65dc6a5 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -397,6 +397,7 @@ type DashboardMetrics struct { CancellationsCount int UtilizationPercent int CompletedCount int + NewClientsCount int } type ReminderJobParams struct { @@ -1488,6 +1489,21 @@ func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID strin } } } + firstSeen := map[string]time.Time{} + for _, booking := range r.bookings { + if booking.TenantID != tenantID { + continue + } + first, ok := firstSeen[booking.CustomerEmail] + if !ok || booking.StartsAt.Before(first) { + firstSeen[booking.CustomerEmail] = booking.StartsAt + } + } + for _, first := range firstSeen { + if !first.Before(startsAt) && first.Before(endsAt) { + metrics.NewClientsCount++ + } + } if metrics.BookingsCount == 0 { metrics.UtilizationPercent = 0 } else { diff --git a/apps/frontend/src/routes/dashboard/dashboard-data.ts b/apps/frontend/src/routes/dashboard/dashboard-data.ts index 64faf51..15802b5 100644 --- a/apps/frontend/src/routes/dashboard/dashboard-data.ts +++ b/apps/frontend/src/routes/dashboard/dashboard-data.ts @@ -216,6 +216,7 @@ export function DashboardDataProvider(props: { children: any }) { completed: "dashboard.kpi.completed", cancellations: "dashboard.kpi.cancellations", utilization: "dashboard.kpi.utilization", + new_clients: "dashboard.kpi.newClients", }; const normalizedKpis = createMemo(() => (resolvedSummary()?.kpis ?? []).map((kpi: any) => ({ ...kpi, diff --git a/apps/frontend/src/routes/dashboard/overview-page.tsx b/apps/frontend/src/routes/dashboard/overview-page.tsx index 3f6e640..dad8717 100644 --- a/apps/frontend/src/routes/dashboard/overview-page.tsx +++ b/apps/frontend/src/routes/dashboard/overview-page.tsx @@ -37,7 +37,7 @@ function OverviewPage() { {/* KPI Cards */} -
+
{data.normalizedKpis().map((kpi: any, index: number) => ( ))} diff --git a/project.md b/project.md index 475fcd0..5f81fdf 100644 --- a/project.md +++ b/project.md @@ -96,6 +96,11 @@ - reschedule hardened with the same validation as booking creation: appointment reschedules must fit an availability rule and avoid blocked days (`validateAppointmentTarget` is now a shared package-level helper); class bookings keep session-defined times - new repository methods `CancelPendingReminderJobsForBooking` / `DeletePendingReminderJobsForBooking` in pgx and in-memory implementations - remaining: live staging validation; SMS reminder channel exists in schema (`channel` column) but only `email` is dispatched — SMS reminders are a separate opt-in feature +- `[done/partial]` Milestone 14: Metrics completion + reminder dispatch scheduling + - `DashboardMetrics` gained `NewClientsCount` — distinct customer emails whose earliest-ever booking (`starts_at`) lands in the window; a fifth KPI `new_clients` completes the set the demo data promised + - KPI grid rebalanced for five cards (`lg:grid-cols-3 xl:grid-cols-5`); `new_clients` maps to the existing `dashboard.kpi.newClients` i18n key + - reminder dispatch now has a real trigger: `.github/workflows/reminder-dispatch.yml` calls `POST /v1/internal/jobs/reminders/dispatch` every 15 minutes with `X-Bookra-Job-Key` from secrets (`BOOKRA_API_URL`, `BOOKRA_JOB_RUNNER_KEY`); jobs are timestamp-based so scheduler lateness delays but never skips + - remaining: live staging validation; the workflow no-ops gracefully until the two secrets are configured ## 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. From 7509e82261dad29aacf145ef33e80bceff87a63e Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 13:03:11 +0200 Subject: [PATCH 08/13] feat(reminders): SMS channel for reminder jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenants paying for the SMS add-on still got email-only reminders — the channel column existed but nothing armed or dispatched sms jobs. Booking creation now adds a second job when SMS is enabled and the customer left a phone; dispatch routes sms jobs through an injected sender adapter wired to the existing sms.Service, so monthly limits, usage metering, and Stripe billing apply unchanged. Lifecycle handling from the previous milestone clears and re-arms both channels together. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/internal/api/server.go | 4 ++ apps/backend/internal/bookings/service.go | 11 ++++ .../backend/internal/bookings/service_test.go | 55 +++++++++++++++++++ apps/backend/internal/db/bookings.go | 3 +- apps/backend/internal/db/repository.go | 5 ++ .../backend/internal/notifications/service.go | 34 ++++++++++++ .../internal/notifications/service_test.go | 47 ++++++++++++++++ project.md | 6 ++ 8 files changed, 164 insertions(+), 1 deletion(-) diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 2aab16b..3a7fc75 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -57,6 +57,10 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { authService := auth.NewService(repository, cfg.AuthJWTSecret) adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey) smsService := sms.NewService(cfg, repository) + notificationService.SetSMSSender(func(ctx context.Context, tenantID, phone, body string) error { + _, err := smsService.SendMessage(ctx, tenantID, domain.SendSMSRequest{To: phone, Body: body}) + return err + }) publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5) server := &Server{ diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 6e32cfb..9c817d4 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -366,6 +366,17 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques }); err != nil { return domain.CreateBookingResponse{}, err } + // SMS reminders are a per-tenant opt-in and need a customer phone. + if request.CustomerPhone != "" { + if smsSettings, err := s.repo.GetTenantSMSSettings(ctx, tenant.ID); err == nil && smsSettings.Enabled { + _ = s.repo.CreateReminderJob(ctx, db.ReminderJobParams{ + TenantID: tenant.ID, + BookingID: created.ID, + Channel: "sms", + ScheduledFor: scheduledFor, + }) + } + } } // Send confirmation emails (async - don't fail booking if email fails) diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index 2e38fbd..b819333 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -648,3 +648,58 @@ func TestRescheduleRejectsTimeOutsideAvailabilityWindow(t *testing.T) { t.Fatalf("expected ErrInvalidReschedule, got %v", err) } } + +func TestCreateArmsSmsReminderWhenEnabled(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + ctx := context.Background() + + membership, _ := repo.GetTenantMembershipBySubject(ctx, "demo-owner") + if err := repo.UpsertTenantSMSSettings(ctx, db.TenantSMSSettingsRecord{ + TenantID: membership.Tenant.ID, + Enabled: true, + }); err != nil { + t.Fatalf("sms settings: %v", err) + } + + // Booking with a phone: expect email + sms reminder jobs. + availability, err := service.Availability(ctx, "studio-atelier", AvailabilityFilter{ + Date: time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02"), + }) + if err != nil { + t.Fatalf("availability: %v", err) + } + var slot domain.TimeSlot + for _, s := range availability.Slots { + if s.Mode == "appointment" { + slot = s + break + } + } + resp, err := service.Create(ctx, domain.CreateBookingRequest{ + TenantSlug: "studio-atelier", + BookingMode: "appointment", + ServiceID: slot.ServiceID, + StaffID: slot.StaffID, + LocationID: slot.LocationID, + CustomerName: "SMS Test", + CustomerEmail: "sms@test.cz", + CustomerPhone: "+420777123456", + StartsAt: slot.StartsAt, + EndsAt: slot.EndsAt, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + jobs := pendingJobsFor(t, repo, resp.BookingID) + if len(jobs) != 2 { + t.Fatalf("expected 2 pending jobs, got %d", len(jobs)) + } + channels := map[string]bool{} + for _, job := range jobs { + channels[job.Channel] = true + } + if !channels["email"] || !channels["sms"] { + t.Fatalf("expected email+sms channels, got %v", channels) + } +} diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 163da14..4c0df3d 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -108,7 +108,7 @@ func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.T rows, err := r.pool.Query(ctx, ` SELECT rj.id, rj.tenant_id, t.name, t.locale, t.timezone, rj.booking_id, rj.channel, rj.scheduled_for, - b.customer_name, b.customer_email, b.reference, b.starts_at, rj.status, b.status + b.customer_name, b.customer_email, b.customer_phone, b.reference, b.starts_at, rj.status, b.status FROM reminder_jobs rj INNER JOIN bookings b ON b.id = rj.booking_id INNER JOIN tenants t ON t.id = rj.tenant_id @@ -135,6 +135,7 @@ func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.T &record.ScheduledFor, &record.CustomerName, &record.CustomerEmail, + &record.CustomerPhone, &record.Reference, &record.StartsAt, &record.Status, diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 65dc6a5..33daead 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -418,6 +418,7 @@ type ReminderJobRecord struct { ScheduledFor time.Time CustomerName string CustomerEmail string + CustomerPhone string Reference string StartsAt time.Time Status string @@ -1386,12 +1387,14 @@ func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJ startsAt := time.Now().UTC() customerName := "Booking Customer" customerEmail := "customer@example.com" + customerPhone := "" for _, booking := range r.bookings { if booking.ID == params.BookingID { reference = booking.Reference startsAt = booking.StartsAt customerName = booking.CustomerName customerEmail = booking.CustomerEmail + customerPhone = booking.CustomerPhone break } } @@ -1406,6 +1409,7 @@ func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJ ScheduledFor: params.ScheduledFor, CustomerName: customerName, CustomerEmail: customerEmail, + CustomerPhone: customerPhone, Reference: reference, StartsAt: startsAt, Status: "pending", @@ -1420,6 +1424,7 @@ func (r *MemoryRepository) ListDueReminderJobs(_ context.Context, dueBefore time for _, booking := range r.bookings { if booking.ID == job.BookingID { job.BookingStatus = booking.Status + job.CustomerPhone = booking.CustomerPhone break } } diff --git a/apps/backend/internal/notifications/service.go b/apps/backend/internal/notifications/service.go index b8298cd..01b6e6d 100644 --- a/apps/backend/internal/notifications/service.go +++ b/apps/backend/internal/notifications/service.go @@ -37,6 +37,7 @@ type Service struct { cfg config.Config repo db.Repository emailProvider EmailProvider + smsSender func(ctx context.Context, tenantID, phone, body string) error now func() time.Time } @@ -54,6 +55,13 @@ func NewService(cfg config.Config, repo db.Repository) *Service { } } +// SetSMSSender plugs in the SMS delivery path used for sms-channel reminder +// jobs. Kept as a function adapter so this package stays free of the sms +// dependency; the concrete sms.Service is wired in the API server. +func (s *Service) SetSMSSender(sender func(ctx context.Context, tenantID, phone, body string) error) { + s.smsSender = sender +} + func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchReminderJobsResponse, error) { if limit <= 0 { limit = 25 @@ -91,6 +99,21 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe provider = receipt.Provider externalID = receipt.ExternalID } + case "sms": + provider = "smsmanager" + switch { + case s.smsSender == nil: + status = "failed" + errorMessage = "sms delivery not configured" + case job.CustomerPhone == "": + status = "failed" + errorMessage = "missing customer phone" + default: + if sendErr := s.smsSender(ctx, job.TenantID, job.CustomerPhone, renderSmsBody(job)); sendErr != nil { + status = "failed" + errorMessage = sendErr.Error() + } + } default: status = "failed" errorMessage = ErrUnsupportedChannel.Error() @@ -275,7 +298,18 @@ func localizedStartsAt(job db.ReminderJobRecord) string { return localStartsAt.Format("Jan 02, 2006 15:04") } +func renderSmsBody(job db.ReminderJobRecord) string { + startLabel := localizedStartsAt(job) + if job.Locale == "cs" { + return fmt.Sprintf("%s: pripominka rezervace %s na %s.", job.TenantName, job.Reference, startLabel) + } + return fmt.Sprintf("%s: reminder for booking %s at %s.", job.TenantName, job.Reference, startLabel) +} + func reminderRecipient(job db.ReminderJobRecord) string { + if job.Channel == "sms" { + return job.CustomerPhone + } return job.CustomerEmail } diff --git a/apps/backend/internal/notifications/service_test.go b/apps/backend/internal/notifications/service_test.go index 4676713..f6f4e5a 100644 --- a/apps/backend/internal/notifications/service_test.go +++ b/apps/backend/internal/notifications/service_test.go @@ -138,3 +138,50 @@ func TestDispatchDueSkipsNonConfirmedBooking(t *testing.T) { t.Fatalf("expected 1 skipped, got %+v", response) } } + +func TestDispatchDueSendsSmsReminder(t *testing.T) { + repo := db.NewMemoryRepository() + startsAt := time.Now().UTC().Add(26 * time.Hour) + created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{ + TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", + BookingMode: "appointment", + CustomerPhone: "+420777000111", + StartsAt: startsAt, + EndsAt: startsAt.Add(time.Hour), + Status: "confirmed", + Reference: "BK-TEST-SMS", + }) + if err != nil { + t.Fatalf("create booking: %v", err) + } + if err := repo.CreateReminderJob(context.Background(), db.ReminderJobParams{ + TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", + BookingID: created.ID, + Channel: "sms", + ScheduledFor: time.Now().UTC().Add(-time.Minute), + }); err != nil { + t.Fatalf("create reminder job: %v", err) + } + + var sentPhone, sentBody string + service := NewService(config.Config{Environment: "development"}, repo) + service.SetSMSSender(func(_ context.Context, tenantID, phone, body string) error { + sentPhone = phone + sentBody = body + return nil + }) + + response, err := service.DispatchDue(context.Background(), 10) + if err != nil { + t.Fatalf("dispatch due: %v", err) + } + if response.SentCount != 1 { + t.Fatalf("expected 1 sent, got %+v", response) + } + if sentPhone != "+420777000111" { + t.Fatalf("expected phone recipient, got %q", sentPhone) + } + if sentBody == "" { + t.Fatal("expected rendered sms body") + } +} diff --git a/project.md b/project.md index 5f81fdf..e90a6f1 100644 --- a/project.md +++ b/project.md @@ -101,6 +101,12 @@ - KPI grid rebalanced for five cards (`lg:grid-cols-3 xl:grid-cols-5`); `new_clients` maps to the existing `dashboard.kpi.newClients` i18n key - reminder dispatch now has a real trigger: `.github/workflows/reminder-dispatch.yml` calls `POST /v1/internal/jobs/reminders/dispatch` every 15 minutes with `X-Bookra-Job-Key` from secrets (`BOOKRA_API_URL`, `BOOKRA_JOB_RUNNER_KEY`); jobs are timestamp-based so scheduler lateness delays but never skips - remaining: live staging validation; the workflow no-ops gracefully until the two secrets are configured +- `[done/partial]` Milestone 15: SMS reminder channel + - booking creation arms a second reminder job on `channel: sms` when the tenant has SMS enabled (`tenant_sms_settings.enabled`) and the customer left a phone number; email jobs are unchanged + - `notifications.Service` gained an injected `smsSender` function adapter (wired to `sms.Service.SendMessage` in the API server, keeping the package dependency-free); dispatch routes `channel: sms` through it with localized body text and the customer phone as recipient + - `sms.Service.SendMessage` still enforces enabled/plan/monthly-limit and logs usage, so SMS reminders inherit metering and billing automatically; missing provider config or phone marks the job `failed` with a delivery-log reason + - reminder lifecycle work from milestone 13 applies per-booking, so cancel/reschedule clears and re-arms both channels + - remaining: live staging validation against a real SMS Manager key ## 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. From 3892e9ce8c589f5a5f00325fb00c33ad81842df7 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 13:07:06 +0200 Subject: [PATCH 09/13] feat(waitlist): queue position on the customer manage page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A waitlisted customer saw only a bare status — worse, the badge fell back to "Pending" for both waitlisted and cancelled states. The view now carries a 1-based waitlistPosition (earlier-created waitlisted bookings on the session + 1) and the page renders a warning-tone notice with the queue spot and auto-promotion explanation, in CS/EN. Also fixed the badge's nonexistent --danger-soft/--warning-soft tokens. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal/bookings/customer_service.go | 26 +++++--- .../backend/internal/bookings/service_test.go | 63 +++++++++++++++++++ apps/backend/internal/db/bookings.go | 12 ++++ apps/backend/internal/db/repository.go | 22 +++++++ apps/backend/internal/domain/models.go | 3 + .../src/routes/booking-manage-route.tsx | 38 +++++++++-- project.md | 5 ++ 7 files changed, 155 insertions(+), 14 deletions(-) diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index 506b219..6679193 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -108,17 +108,23 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s serviceName, locationName := s.descriptors(ctx, booking) + position := 0 + if booking.Status == "waitlisted" && booking.ClassSessionID != nil { + position, _ = s.repo.GetWaitlistPosition(ctx, *booking.ClassSessionID, booking.ID) + } + return domain.CustomerBookingView{ - Reference: booking.Reference, - CustomerName: booking.CustomerName, - CustomerEmail: booking.CustomerEmail, - Service: serviceName, - BusinessName: tenant.Name, - StartsAt: booking.StartsAt, - EndsAt: booking.EndsAt, - Location: locationName, - Status: booking.Status, - Notes: booking.Notes, + Reference: booking.Reference, + CustomerName: booking.CustomerName, + CustomerEmail: booking.CustomerEmail, + Service: serviceName, + BusinessName: tenant.Name, + StartsAt: booking.StartsAt, + EndsAt: booking.EndsAt, + Location: locationName, + Status: booking.Status, + Notes: booking.Notes, + WaitlistPosition: position, }, nil } diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index b819333..24488cf 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -703,3 +703,66 @@ func TestCreateArmsSmsReminderWhenEnabled(t *testing.T) { t.Fatalf("expected email+sms channels, got %v", channels) } } + +func TestWaitlistPositionReflectsQueueOrder(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + customerService := NewCustomerService(repo, nil, "http://localhost:3000") + ctx := context.Background() + + session, err := repo.GetClassSessionByID(ctx, "4bf74c12-44dd-45ca-86bb-b104f16f2435") + if err != nil { + t.Fatalf("get session: %v", err) + } + sessionID := session.ID + book := func(email string) domain.CreateBookingResponse { + resp, err := service.Create(ctx, domain.CreateBookingRequest{ + TenantSlug: "studio-atelier", + BookingMode: "class", + ClassSessionID: &sessionID, + CustomerName: "Queue Test", + CustomerEmail: email, + StartsAt: session.StartsAt.UTC().Format(time.RFC3339), + EndsAt: session.EndsAt.UTC().Format(time.RFC3339), + }) + if err != nil { + t.Fatalf("book %s: %v", email, err) + } + return resp + } + + var seats []domain.CreateBookingResponse + for i := 0; i < 4; i++ { + seats = append(seats, book(fmt.Sprintf("qseat%d@example.com", i))) + } + first := book("qfirst@example.com") + second := book("qsecond@example.com") + + positionOf := func(resp domain.CreateBookingResponse) int { + manageURL, _ := url.Parse(resp.ManageURL) + view, err := customerService.GetBookingByReference(ctx, resp.Reference, manageURL.Query().Get("token")) + if err != nil { + t.Fatalf("get booking view: %v", err) + } + return view.WaitlistPosition + } + + if got := positionOf(first); got != 1 { + t.Fatalf("expected first waiter at position 1, got %d", got) + } + if got := positionOf(second); got != 2 { + t.Fatalf("expected second waiter at position 2, got %d", got) + } + + // Free a seat: first waiter is promoted, second moves to the front. + manageURL, _ := url.Parse(seats[0].ManageURL) + if err := customerService.CancelBooking(ctx, seats[0].Reference, manageURL.Query().Get("token")); err != nil { + t.Fatalf("cancel: %v", err) + } + if got := positionOf(second); got != 1 { + t.Fatalf("expected second waiter at position 1 after promotion, got %d", got) + } + if got := positionOf(first); got != 0 { + t.Fatalf("expected promoted booking to report no position, got %d", got) + } +} diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 4c0df3d..c66105f 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -204,6 +204,18 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, return metrics, err } +func (r *PGRepository) GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) { + var position int + err := r.pool.QueryRow(ctx, ` + SELECT COUNT(*) + 1 + FROM bookings + WHERE class_session_id = $1 + AND status = 'waitlisted' + AND created_at < (SELECT created_at FROM bookings WHERE id = $2) + `, sessionID, bookingID).Scan(&position) + return position, err +} + func (r *PGRepository) CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error { _, err := r.pool.Exec(ctx, ` UPDATE reminder_jobs diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 33daead..db62e96 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -107,6 +107,7 @@ type Repository interface { // Customer Booking Management GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error) GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error) + GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) UpdateBookingStatus(ctx context.Context, bookingID string, status string) error RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error @@ -1844,6 +1845,27 @@ func (r *MemoryRepository) GetBookingByID(_ context.Context, bookingID string) ( return BookingRecord{}, pgx.ErrNoRows } +func (r *MemoryRepository) GetWaitlistPosition(_ context.Context, sessionID, bookingID string) (int, error) { + var self *BookingRecord + for index := range r.bookings { + if r.bookings[index].ID == bookingID { + self = &r.bookings[index] + break + } + } + if self == nil { + return 0, pgx.ErrNoRows + } + position := 1 + for _, booking := range r.bookings { + if booking.ClassSessionID != nil && *booking.ClassSessionID == sessionID && + booking.Status == "waitlisted" && booking.CreatedAt.Before(self.CreatedAt) { + position++ + } + } + return position, nil +} + func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error { for i, b := range r.bookings { if b.ID == bookingID { diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 7947332..91a9b5d 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -458,6 +458,9 @@ type CustomerBookingView struct { Location string `json:"location"` Status string `json:"status"` Notes string `json:"notes,omitempty"` + // WaitlistPosition is the 1-based queue spot for waitlisted class + // bookings; zero for any other status. + WaitlistPosition int `json:"waitlistPosition,omitempty"` } type RescheduleBookingRequest struct { diff --git a/apps/frontend/src/routes/booking-manage-route.tsx b/apps/frontend/src/routes/booking-manage-route.tsx index 344be83..908bc29 100644 --- a/apps/frontend/src/routes/booking-manage-route.tsx +++ b/apps/frontend/src/routes/booking-manage-route.tsx @@ -195,18 +195,48 @@ export function BookingManageRoute() { {b().businessName}
- {b().status === 'confirmed' + {b().status === 'confirmed' ? (i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed') + : b().status === 'waitlisted' + ? (i18n.locale() === 'cs' ? 'Čekací listina' : 'Waitlisted') + : b().status === 'cancelled' + ? (i18n.locale() === 'cs' ? 'Zrušeno' : 'Cancelled') : (i18n.locale() === 'cs' ? 'Čeká se' : 'Pending') }
+ {/* Waitlist position */} + +
+
+ + + + +
+
+

+ {b().waitlistPosition > 0 + ? (i18n.locale() === 'cs' ? `Jste ${b().waitlistPosition}. v pořadí` : `You are #${b().waitlistPosition} in the queue`) + : (i18n.locale() === 'cs' ? 'Jste na čekací listině' : 'You are on the waitlist')} +

+

+ {i18n.locale() === 'cs' + ? 'Po uvolnění místa vám rezervaci automaticky potvrdíme e-mailem.' + : 'When a seat frees up we will confirm your booking by email automatically.'} +

+
+
+
+ {/* Reference */}
diff --git a/project.md b/project.md index e90a6f1..1cb8cd8 100644 --- a/project.md +++ b/project.md @@ -107,6 +107,11 @@ - `sms.Service.SendMessage` still enforces enabled/plan/monthly-limit and logs usage, so SMS reminders inherit metering and billing automatically; missing provider config or phone marks the job `failed` with a delivery-log reason - reminder lifecycle work from milestone 13 applies per-booking, so cancel/reschedule clears and re-arms both channels - remaining: live staging validation against a real SMS Manager key +- `[done/partial]` Milestone 16: Waitlist position display + - `GetWaitlistPosition` repository method (pgx + memory) returns the 1-based queue spot: count of earlier-created waitlisted bookings on the same session + 1 + - `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN + - fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do) + - remaining: live staging validation; manual waitlist promotion controls are still not exposed ## 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. From 5884203e9d30a738d0f3027e65da1cf6ca19c718 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 13:13:37 +0200 Subject: [PATCH 10/13] feat(catalog): class template editing Templates were delete-and-recreate only. Adds PUT /v1/catalog/class-templates/{id} across repository, service (sharing the create-time validation), route, spec, and client; the classes tab gets a pencil action that loads the row into the form with a save/cancel affordance. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/internal/api/server.go | 20 +++++++ apps/backend/internal/catalog/service.go | 57 ++++++++++++++++--- apps/backend/internal/catalog/service_test.go | 40 +++++++++++++ apps/backend/internal/db/classes.go | 18 ++++++ apps/backend/internal/db/repository.go | 13 +++++ apps/backend/openapi/bookra.openapi.yaml | 25 ++++++++ .../src/components/dashboard/icons.tsx | 6 ++ apps/frontend/src/providers/i18n-provider.tsx | 2 + .../src/routes/dashboard/zones-page.tsx | 45 +++++++++++---- packages/api-client/src/generated/types.ts | 40 ++++++++++++- project.md | 4 ++ 11 files changed, 249 insertions(+), 21 deletions(-) diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 3a7fc75..6efc855 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -998,6 +998,26 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { } c.JSON(http.StatusCreated, response) }) + protected.PUT("/catalog/class-templates/:templateID", func(c *gin.Context) { + var request domain.UpsertClassTemplateRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + response, err := catalogService.UpdateClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("templateID"), request) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassTemplateNotFound): + status = http.StatusNotFound + case errors.Is(err, catalog.ErrInvalidInput): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) protected.DELETE("/catalog/class-templates/:templateID", func(c *gin.Context) { err := catalogService.DeleteClassTemplate(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("templateID")) if err != nil { diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index 25684f4..21a7858 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -1050,15 +1050,9 @@ func (s *Service) CreateClassTemplate(ctx context.Context, principal domain.Prin return domain.ClassTemplate{}, ErrTenantMembership } - title := strings.TrimSpace(req.Title) - if len(title) < 1 || len(title) > 120 { - return domain.ClassTemplate{}, fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput) - } - if req.DurationMinutes < 1 || req.DurationMinutes > 1440 { - return domain.ClassTemplate{}, fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput) - } - if req.Capacity < 1 || req.Capacity > 500 { - return domain.ClassTemplate{}, fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput) + title, err := validateClassTemplateInput(req) + if err != nil { + return domain.ClassTemplate{}, err } rec, err := s.repo.CreateClassTemplate(ctx, db.CreateClassTemplateParams{ @@ -1372,3 +1366,48 @@ func (s *Service) sendBookingEmail(booking db.BookingRecord, promoted bool) { s.notificationService.SendBookingCancellation(ctx, data) } } + +func (s *Service) UpdateClassTemplate(ctx context.Context, principal domain.Principal, templateID string, req domain.UpsertClassTemplateRequest) (domain.ClassTemplate, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return domain.ClassTemplate{}, ErrTenantMembership + } + if err := s.requireTenantClassTemplate(ctx, membership.Tenant.ID, templateID); err != nil { + return domain.ClassTemplate{}, err + } + title, err := validateClassTemplateInput(req) + if err != nil { + return domain.ClassTemplate{}, err + } + rec, err := s.repo.UpdateClassTemplate(ctx, templateID, db.CreateClassTemplateParams{ + TenantID: membership.Tenant.ID, + Title: title, + DurationMinutes: req.DurationMinutes, + Capacity: req.Capacity, + }) + if err != nil { + return domain.ClassTemplate{}, err + } + return domain.ClassTemplate{ + ID: rec.ID, + TenantID: rec.TenantID, + Title: rec.Title, + DurationMinutes: rec.DurationMinutes, + Capacity: rec.Capacity, + CreatedAt: rec.CreatedAt, + }, nil +} + +func validateClassTemplateInput(req domain.UpsertClassTemplateRequest) (string, error) { + title := strings.TrimSpace(req.Title) + if len(title) < 1 || len(title) > 120 { + return "", fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput) + } + if req.DurationMinutes < 1 || req.DurationMinutes > 1440 { + return "", fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput) + } + if req.Capacity < 1 || req.Capacity > 500 { + return "", fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput) + } + return title, nil +} diff --git a/apps/backend/internal/catalog/service_test.go b/apps/backend/internal/catalog/service_test.go index 1d5adba..5f5b45a 100644 --- a/apps/backend/internal/catalog/service_test.go +++ b/apps/backend/internal/catalog/service_test.go @@ -640,3 +640,43 @@ func TestCancelBookingPromotesWaitlist(t *testing.T) { t.Fatalf("expected second waiter promoted, got %q", booking.Status) } } + +func TestUpdateClassTemplate(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + created, err := svc.CreateClassTemplate(ctx, owner, domain.UpsertClassTemplateRequest{ + Title: "Yin Yoga", DurationMinutes: 75, Capacity: 10, + }) + if err != nil { + t.Fatalf("create template: %v", err) + } + + updated, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{ + Title: "Yin Yoga (večer)", DurationMinutes: 90, Capacity: 12, + }) + if err != nil { + t.Fatalf("update template: %v", err) + } + if updated.Title != "Yin Yoga (večer)" || updated.DurationMinutes != 90 || updated.Capacity != 12 { + t.Fatalf("unexpected updated template: %+v", updated) + } + + if _, err := svc.UpdateClassTemplate(ctx, owner, created.ID, domain.UpsertClassTemplateRequest{ + Title: "", DurationMinutes: 90, Capacity: 12, + }); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput, got %v", err) + } + stranger := domain.Principal{Subject: "someone-else", Role: "staff"} + if _, err := svc.UpdateClassTemplate(ctx, stranger, created.ID, domain.UpsertClassTemplateRequest{ + Title: "X", DurationMinutes: 60, Capacity: 5, + }); !errors.Is(err, ErrTenantMembership) { + t.Fatalf("expected ErrTenantMembership, got %v", err) + } + if _, err := svc.UpdateClassTemplate(ctx, owner, "00000000-0000-0000-0000-000000000099", domain.UpsertClassTemplateRequest{ + Title: "X", DurationMinutes: 60, Capacity: 5, + }); !errors.Is(err, ErrClassTemplateNotFound) { + t.Fatalf("expected ErrClassTemplateNotFound, got %v", err) + } +} diff --git a/apps/backend/internal/db/classes.go b/apps/backend/internal/db/classes.go index fbedebd..d3e76f0 100644 --- a/apps/backend/internal/db/classes.go +++ b/apps/backend/internal/db/classes.go @@ -71,6 +71,24 @@ func (r *PGRepository) CreateClassTemplate(ctx context.Context, params CreateCla return record, err } +func (r *PGRepository) UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) { + var record ClassTemplateRecord + err := r.pool.QueryRow(ctx, ` + UPDATE class_templates + SET title = $2, duration_minutes = $3, capacity = $4 + WHERE id = $1 + RETURNING `+classTemplateColumns+` + `, templateID, params.Title, params.DurationMinutes, params.Capacity).Scan( + &record.ID, + &record.TenantID, + &record.Title, + &record.DurationMinutes, + &record.Capacity, + &record.CreatedAt, + ) + return record, err +} + func (r *PGRepository) DeleteClassTemplate(ctx context.Context, templateID string) error { _, err := r.pool.Exec(ctx, `DELETE FROM class_templates WHERE id = $1`, templateID) return err diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index db62e96..d14e089 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -32,6 +32,7 @@ type Repository interface { ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error) GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error) CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) + UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) DeleteClassTemplate(ctx context.Context, templateID string) error ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) @@ -1311,6 +1312,18 @@ func (r *MemoryRepository) CreateClassTemplate(_ context.Context, params CreateC return record, nil } +func (r *MemoryRepository) UpdateClassTemplate(_ context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) { + for index := range r.classTemplates { + if r.classTemplates[index].ID == templateID { + r.classTemplates[index].Title = params.Title + r.classTemplates[index].DurationMinutes = params.DurationMinutes + r.classTemplates[index].Capacity = params.Capacity + return r.classTemplates[index], nil + } + } + return ClassTemplateRecord{}, pgx.ErrNoRows +} + func (r *MemoryRepository) DeleteClassTemplate(_ context.Context, templateID string) error { for i, template := range r.classTemplates { if template.ID == templateID { diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 2866f3c..462f4ba 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -1099,6 +1099,31 @@ paths: "400": description: Invalid request /v1/catalog/class-templates/{templateID}: + put: + tags: [Catalog] + operationId: updateClassTemplate + security: + - bearerAuth: [] + parameters: + - in: path + name: templateID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertClassTemplateRequest" + responses: + "200": + description: Updated class template + "400": + description: Invalid input + "404": + description: Template not found delete: tags: [Catalog] operationId: deleteClassTemplate diff --git a/apps/frontend/src/components/dashboard/icons.tsx b/apps/frontend/src/components/dashboard/icons.tsx index c55580f..d05fd56 100644 --- a/apps/frontend/src/components/dashboard/icons.tsx +++ b/apps/frontend/src/components/dashboard/icons.tsx @@ -114,6 +114,12 @@ export const PlusIcon = () => ( ); +export const PencilIcon = () => ( + + + +); + export const ScissorsIcon = () => ( diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 61cbb0a..690e29b 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -411,6 +411,7 @@ const dictionaries = { "dashboard.class.templates": "Šablony lekcí", "dashboard.class.sessions": "Naplánované lekce", "dashboard.class.addTemplate": "Nová šablona", + "dashboard.class.saveChanges": "Uložit změny", "dashboard.class.addSession": "Naplánovat lekci", "dashboard.class.title": "Název", "dashboard.class.titlePlaceholder": "např. Jóga pro začátečníky", @@ -986,6 +987,7 @@ const dictionaries = { "dashboard.class.templates": "Class templates", "dashboard.class.sessions": "Scheduled sessions", "dashboard.class.addTemplate": "New template", + "dashboard.class.saveChanges": "Save changes", "dashboard.class.addSession": "Schedule session", "dashboard.class.title": "Title", "dashboard.class.titlePlaceholder": "e.g. Beginner yoga", diff --git a/apps/frontend/src/routes/dashboard/zones-page.tsx b/apps/frontend/src/routes/dashboard/zones-page.tsx index e5c6f41..fe5b3b8 100644 --- a/apps/frontend/src/routes/dashboard/zones-page.tsx +++ b/apps/frontend/src/routes/dashboard/zones-page.tsx @@ -2,7 +2,7 @@ import { For, Show, createResource, createSignal } from "solid-js"; import { apiClient } from "../../lib/api-client"; import { Input } from "../../components/ui/input"; import { Select } from "../../components/ui/select"; -import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon } from "../../components/dashboard/icons"; +import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon } from "../../components/dashboard/icons"; import { DashboardLayout, useDashboardData } from "./layout"; function ZonesPage() { @@ -24,6 +24,7 @@ function ZonesPage() { const [newTemplateDuration, setNewTemplateDuration] = createSignal(60); const [newTemplateCapacity, setNewTemplateCapacity] = createSignal(8); const [templateSaving, setTemplateSaving] = createSignal(false); + const [editingTemplateId, setEditingTemplateId] = createSignal(null); const [newSessionTemplateId, setNewSessionTemplateId] = createSignal(""); const [newSessionLocationId, setNewSessionLocationId] = createSignal(""); const [newSessionStartsAt, setNewSessionStartsAt] = createSignal(""); @@ -211,18 +212,25 @@ function ZonesPage() { } }; - const handleAddTemplate = async () => { + const resetTemplateForm = () => { + setNewTemplateTitle(""); setNewTemplateDuration(60); setNewTemplateCapacity(8); setEditingTemplateId(null); + }; + + const handleSaveTemplate = async () => { const bearer = data.token(); if (!newTemplateTitle().trim()) return; if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; } setTemplateSaving(true); try { - await (apiClient as any).POST("/v1/catalog/class-templates", { - ...authHeaders(), - body: { title: newTemplateTitle().trim(), durationMinutes: newTemplateDuration(), capacity: newTemplateCapacity() }, - }); - setNewTemplateTitle(""); setNewTemplateDuration(60); setNewTemplateCapacity(8); - void refetchTemplates(); + const body = { title: newTemplateTitle().trim(), durationMinutes: newTemplateDuration(), capacity: newTemplateCapacity() }; + const editing = editingTemplateId(); + if (editing) { + await (apiClient as any).PUT(`/v1/catalog/class-templates/${editing}`, { ...authHeaders(), body }); + } else { + await (apiClient as any).POST("/v1/catalog/class-templates", { ...authHeaders(), body }); + } + resetTemplateForm(); + void refetchTemplates(); void refetchSessions(); } catch { data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed."); } finally { @@ -230,6 +238,13 @@ function ZonesPage() { } }; + const handleEditTemplate = (tpl: any) => { + setNewTemplateTitle(tpl.title); + setNewTemplateDuration(tpl.durationMinutes); + setNewTemplateCapacity(tpl.capacity); + setEditingTemplateId(tpl.id); + }; + const handleDeleteTemplate = async (id: string) => { const bearer = data.token(); if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; } @@ -487,9 +502,14 @@ function ZonesPage() {
- + + +
@@ -509,7 +529,10 @@ function ZonesPage() {

{tpl.durationMinutes} {data.i18n.t("dashboard.service.min")} • {data.i18n.t("dashboard.zone.capacity")}: {tpl.capacity}

- +
+ + +
)} diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index b74e3ea..0f6b11b 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -359,7 +359,7 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; + put: operations["updateClassTemplate"]; post?: never; delete: operations["deleteClassTemplate"]; options?: never; @@ -2220,6 +2220,44 @@ export interface operations { }; }; }; + updateClassTemplate: { + parameters: { + query?: never; + header?: never; + path: { + templateID: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertClassTemplateRequest"]; + }; + }; + responses: { + /** @description Updated class template */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Template not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; deleteClassTemplate: { parameters: { query?: never; diff --git a/project.md b/project.md index 1cb8cd8..66c2577 100644 --- a/project.md +++ b/project.md @@ -112,6 +112,10 @@ - `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN - fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do) - remaining: live staging validation; manual waitlist promotion controls are still not exposed +- `[done/partial]` Milestone 17: Class template editing + - `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client + - classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance + - remaining: live staging validation ## 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. From 10df4e4c644f9912fe4a4491a2d668413aa8e143 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 13:16:43 +0200 Subject: [PATCH 11/13] feat(booking): day-grouped class list with honest capacity states Class sessions rendered as a flat stack of tall cards below the stepped picker, and a full session still offered "Book now" while silently producing a waitlisted booking. Sessions now group by day with compact timetable rows; empty sessions show Full and switch the action to Join waitlist, and low capacity shows a warning count. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/frontend/src/providers/i18n-provider.tsx | 4 + .../src/routes/public-booking-route.tsx | 102 ++++++++++-------- project.md | 4 + 3 files changed, 63 insertions(+), 47 deletions(-) diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 690e29b..314b64d 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -512,6 +512,8 @@ const dictionaries = { "booking.spotsAvailable": "volná místa", "booking.submitting": "Rezervuji...", "booking.submit": "Rezervovat", + "booking.joinWaitlist": "Na čekací listinu", + "booking.full": "Obsazeno", "booking.business": "Podnik", "booking.sidebar.body": "Rezervace proběhne online. Potvrzení a připomenutí přijdou e-mailem.", "booking.customer.title": "Kontaktní údaje", @@ -1088,6 +1090,8 @@ const dictionaries = { "booking.spotsAvailable": "spots available", "booking.submitting": "Booking...", "booking.submit": "Book now", + "booking.joinWaitlist": "Join waitlist", + "booking.full": "Full", "booking.business": "Business", "booking.sidebar.body": "Book online. Confirmation and reminders are sent by email.", "booking.customer.title": "Contact details", diff --git a/apps/frontend/src/routes/public-booking-route.tsx b/apps/frontend/src/routes/public-booking-route.tsx index ac09a96..89bbd97 100644 --- a/apps/frontend/src/routes/public-booking-route.tsx +++ b/apps/frontend/src/routes/public-booking-route.tsx @@ -69,6 +69,18 @@ export function PublicBookingRoute() { const services = createMemo(() => catalog.latest?.data?.services ?? []); const staff = createMemo(() => catalog.latest?.data?.staff ?? []); const classSlots = createMemo(() => (availability.latest?.data?.slots ?? []).filter((slot) => slot.mode === "class")); + const classDays = createMemo(() => { + const groups = new Map(); + for (const slot of classSlots()) { + const d = new Date(slot.startsAt); + const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + const day = groups.get(key); + if (day) day.push(slot); else groups.set(key, [slot]); + } + return [...groups.entries()] + .sort(([, a], [, b]) => a[0].startsAt.localeCompare(b[0].startsAt)) + .map(([key, slots]) => ({ key, slots, date: new Date(slots[0].startsAt) })); + }); const daySlots = createMemo(() => dayAvailability.latest?.data?.slots ?? []); const hasCatalog = createMemo(() => services().length > 0); @@ -505,55 +517,51 @@ export function PublicBookingRoute() { 0}>

{i18n.t("booking.classes")}

- - {(slot) => ( - -
-
-
- - {slot.mode} - - - {i18n.t("booking.only")} {slot.remainingCapacity} {i18n.t("booking.left")} - -
-

{slot.label ?? "Bookable slot"}

-
- - - - - {new Date(slot.startsAt).toLocaleDateString(i18n.locale(), { weekday: 'short', month: 'short', day: 'numeric' })} - - - - - - {new Date(slot.startsAt).toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' })} - {new Date(slot.endsAt).toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' })} - -
- -

- - - - {slot.remainingCapacity} {i18n.t("booking.spotsAvailable")} -

-
+
+ + {(day) => ( +
+

+ {day.date.toLocaleDateString(i18n.locale(), { weekday: 'long', day: 'numeric', month: 'long' })} +

+
+ + {(slot) => ( +
+
+ + {new Date(slot.startsAt).toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' })} + {" – "} + {new Date(slot.endsAt).toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' })} + +
+

{slot.label ?? "Bookable slot"}

+ +

+ {slot.remainingCapacity === 0 + ? i18n.t("booking.full") + : `${slot.remainingCapacity} ${i18n.t("booking.spotsAvailable")}`} +

+
+
+
+ +
+ )} +
-
- - )} -
+ )} + +
diff --git a/project.md b/project.md index 66c2577..29fb89a 100644 --- a/project.md +++ b/project.md @@ -116,6 +116,10 @@ - `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client - classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance - remaining: live staging validation +- `[done/partial]` Milestone 18: Public class list polish + - class sessions on the public booking page are now grouped by day (localized weekday + date headers) with compact timetable-style rows instead of one tall card per session — consistent with the stepped appointment picker above + - capacity states read honestly: spots-left in success/warning tones, `Obsazeno`/`Full` when empty, and the action switches to `Na čekací listinu`/`Join waitlist` (previously a full session still said "Book now" while silently producing a waitlisted booking) + - remaining: live staging validation ## 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. From e4d95bf504f30ef99966cc4fbde836a74c524d53 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 13:18:39 +0200 Subject: [PATCH 12/13] fix(reminders): re-arm reminder jobs on waitlist promotion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A booking promoted off the waitlist after its reminder had already dispatched-as-skipped went unreminded. Promotion now clears pending jobs and re-arms via the shared armReminderJobs helper — extracted from booking creation so both paths arm email plus opt-in SMS. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/internal/bookings/lifecycle.go | 34 +++++++++++++++++++++ apps/backend/internal/bookings/service.go | 22 ++----------- project.md | 1 + 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/apps/backend/internal/bookings/lifecycle.go b/apps/backend/internal/bookings/lifecycle.go index 5909d30..c3d65b5 100644 --- a/apps/backend/internal/bookings/lifecycle.go +++ b/apps/backend/internal/bookings/lifecycle.go @@ -57,8 +57,42 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI return promoted, err } _ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail) + // A booking promoted after its reminder already dispatched-as-skipped + // would otherwise go unreminded; re-arm whatever the horizon allows. + _ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID) + _ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone) confirmed++ promoted = append(promoted, booking) } return promoted, nil } + +// armReminderJobs schedules the email reminder — and the SMS reminder when the +// tenant opted into SMS and a phone number is on file — for a booking whose +// start still falls inside a reminder horizon. A start too close or too far +// away simply arms nothing. +func armReminderJobs(ctx context.Context, repo db.Repository, tenantID, bookingID string, startsAt time.Time, customerPhone string) error { + scheduledFor, ok := reminderSchedule(startsAt) + if !ok { + return nil + } + if err := repo.CreateReminderJob(ctx, db.ReminderJobParams{ + TenantID: tenantID, + BookingID: bookingID, + Channel: "email", + ScheduledFor: scheduledFor, + }); err != nil { + return err + } + if customerPhone != "" { + if smsSettings, err := repo.GetTenantSMSSettings(ctx, tenantID); err == nil && smsSettings.Enabled { + _ = repo.CreateReminderJob(ctx, db.ReminderJobParams{ + TenantID: tenantID, + BookingID: bookingID, + Channel: "sms", + ScheduledFor: scheduledFor, + }) + } + } + return nil +} diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 9c817d4..6fc9196 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -357,26 +357,8 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques } } - if scheduledFor, ok := reminderSchedule(startsAt); ok { - if err := s.repo.CreateReminderJob(ctx, db.ReminderJobParams{ - TenantID: tenant.ID, - BookingID: created.ID, - Channel: "email", - ScheduledFor: scheduledFor, - }); err != nil { - return domain.CreateBookingResponse{}, err - } - // SMS reminders are a per-tenant opt-in and need a customer phone. - if request.CustomerPhone != "" { - if smsSettings, err := s.repo.GetTenantSMSSettings(ctx, tenant.ID); err == nil && smsSettings.Enabled { - _ = s.repo.CreateReminderJob(ctx, db.ReminderJobParams{ - TenantID: tenant.ID, - BookingID: created.ID, - Channel: "sms", - ScheduledFor: scheduledFor, - }) - } - } + if err := armReminderJobs(ctx, s.repo, tenant.ID, created.ID, startsAt, request.CustomerPhone); err != nil { + return domain.CreateBookingResponse{}, err } // Send confirmation emails (async - don't fail booking if email fails) diff --git a/project.md b/project.md index 29fb89a..756880a 100644 --- a/project.md +++ b/project.md @@ -119,6 +119,7 @@ - `[done/partial]` Milestone 18: Public class list polish - class sessions on the public booking page are now grouped by day (localized weekday + date headers) with compact timetable-style rows instead of one tall card per session — consistent with the stepped appointment picker above - capacity states read honestly: spots-left in success/warning tones, `Obsazeno`/`Full` when empty, and the action switches to `Na čekací listinu`/`Join waitlist` (previously a full session still said "Book now" while silently producing a waitlisted booking) + - reminder arming extracted into a shared `armReminderJobs` helper (email + opt-in SMS) used by booking creation and waitlist promotion — a booking promoted after its reminder already dispatched-as-skipped now gets re-armed instead of going unreminded - remaining: live staging validation ## Overview From 9f92a2026f331a2d19ca6d76b97246c91ad3e043 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 13:27:47 +0200 Subject: [PATCH 13/13] fix(bookings): enforce no-overlap atomically via exclusion constraints Check-then-insert left a race: two concurrent requests for the same slot could both confirm. Migration 00014 adds three partial btree_gist exclusion constraints mirroring resourcesCollide semantics (same staff, same location, both-unassigned), excluding cancelled rows and class bookings. SQLSTATE 23P01 maps to ErrBookingConflict at all three write paths so a lost race returns a clean 409. Validated on a local Postgres 17 container: constraint matrix at SQL level, repo-level 23P01 detection, concurrent burst yielding exactly one confirmed booking. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal/bookings/customer_service.go | 3 ++ apps/backend/internal/bookings/service.go | 3 ++ apps/backend/internal/catalog/service.go | 3 ++ apps/backend/internal/db/bookings.go | 11 ++++++ .../migrations/00014_booking_no_overlap.sql | 37 +++++++++++++++++++ project.md | 6 +++ 6 files changed, 63 insertions(+) create mode 100644 apps/backend/migrations/00014_booking_no_overlap.sql diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index 6679193..3bc02d6 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -187,6 +187,9 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin } if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil { + if db.IsExclusionViolation(err) { + return ErrBookingConflict + } return err } diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 6fc9196..a53cad1 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -341,6 +341,9 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques Notes: notes, }) if err != nil { + if db.IsExclusionViolation(err) { + return domain.CreateBookingResponse{}, ErrBookingConflict + } return domain.CreateBookingResponse{}, err } diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index 21a7858..d7b0962 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -1000,6 +1000,9 @@ func (s *Service) CreateBooking(ctx context.Context, principal domain.Principal, Notes: strings.TrimSpace(req.Notes), }) if err != nil { + if db.IsExclusionViolation(err) { + return domain.UpcomingBooking{}, ErrBookingConflict + } return domain.UpcomingBooking{}, err } diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index c66105f..88549a0 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -2,7 +2,10 @@ package db import ( "context" + "errors" "time" + + "github.com/jackc/pgx/v5/pgconn" ) func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) { @@ -232,3 +235,11 @@ func (r *PGRepository) DeletePendingReminderJobsForBooking(ctx context.Context, `, bookingID) return err } + +// IsExclusionViolation reports whether err is a PostgreSQL exclusion +// constraint violation (SQLSTATE 23P01). The bookings_*_no_overlap +// constraints raise it when a concurrent request wins the race for a slot. +func IsExclusionViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23P01" +} diff --git a/apps/backend/migrations/00014_booking_no_overlap.sql b/apps/backend/migrations/00014_booking_no_overlap.sql new file mode 100644 index 0000000..aa55dbd --- /dev/null +++ b/apps/backend/migrations/00014_booking_no_overlap.sql @@ -0,0 +1,37 @@ +-- +goose Up +-- Hard guarantee against double-booking. The service layer checks for +-- collisions before inserting, but check-then-insert is racy under concurrent +-- requests; these exclusion constraints enforce the same rules atomically. +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- Two appointments may not overlap for the same staff member. +ALTER TABLE bookings + ADD CONSTRAINT bookings_staff_no_overlap + EXCLUDE USING gist ( + tenant_id WITH =, + staff_id WITH =, + tstzrange(starts_at, ends_at) WITH && + ) WHERE (booking_mode = 'appointment' AND status <> 'cancelled' AND staff_id IS NOT NULL); + +-- Two appointments may not overlap in the same location. +ALTER TABLE bookings + ADD CONSTRAINT bookings_location_no_overlap + EXCLUDE USING gist ( + tenant_id WITH =, + location_id WITH =, + tstzrange(starts_at, ends_at) WITH && + ) WHERE (booking_mode = 'appointment' AND status <> 'cancelled' AND location_id IS NOT NULL); + +-- Appointments with no staff or location assignment occupy the tenant's +-- single implicit resource (mirrors resourcesCollide in the service layer). +ALTER TABLE bookings + ADD CONSTRAINT bookings_unassigned_no_overlap + EXCLUDE USING gist ( + tenant_id WITH =, + tstzrange(starts_at, ends_at) WITH && + ) WHERE (booking_mode = 'appointment' AND status <> 'cancelled' AND staff_id IS NULL AND location_id IS NULL); + +-- +goose Down +ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_unassigned_no_overlap; +ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_location_no_overlap; +ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_staff_no_overlap; diff --git a/project.md b/project.md index 756880a..b7a5e84 100644 --- a/project.md +++ b/project.md @@ -121,6 +121,12 @@ - capacity states read honestly: spots-left in success/warning tones, `Obsazeno`/`Full` when empty, and the action switches to `Na čekací listinu`/`Join waitlist` (previously a full session still said "Book now" while silently producing a waitlisted booking) - reminder arming extracted into a shared `armReminderJobs` helper (email + opt-in SMS) used by booking creation and waitlist promotion — a booking promoted after its reminder already dispatched-as-skipped now gets re-armed instead of going unreminded - remaining: live staging validation +- `[done]` Milestone 19: Double-booking race hardening (validated on local Postgres) + - migration `00014_booking_no_overlap.sql`: `btree_gist` extension plus three partial exclusion constraints on `bookings` mirroring `resourcesCollide` semantics — same staff, same location, or both-unassigned appointments may not overlap (`tstzrange`, `[)` bounds so abutting slots are legal); `cancelled` rows and `booking_mode='class'` are excluded + - `db.IsExclusionViolation` maps SQLSTATE 23P01 to `ErrBookingConflict` at all three write paths (public create, customer reschedule, dashboard manual create) — a lost race now surfaces as a proper 409 instead of a silent double-booking + - validated end-to-end against a local `postgres:17` container: SQL-level matrix (staff/location/unassigned overlap blocked, abutting allowed, cancel frees the slot), repo-level 23P01 detection, and a 10-request concurrent burst on one slot producing exactly one 201 + 409s for the rest; down/up migration roundtrip clean + - local test container: `docker run -d --name bookra-pg-local -p 5439:5432 -e POSTGRES_PASSWORD=bookra-local postgres:17-alpine`, migrations via `npm run db:migrate:up` with `BOOKRA_DATABASE_DIRECT_URL` pointed at it + - remaining: live staging validation on Neon ## 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.