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/api/server.go b/apps/backend/internal/api/server.go index 9a4fbc4..6efc855 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{ @@ -323,7 +327,27 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { } server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) { - response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug")) + response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug"), bookings.AvailabilityFilter{ + ServiceID: c.Query("serviceId"), + StaffID: c.Query("staffId"), + Date: c.Query("date"), + }) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, bookings.ErrTenantNotFound): + status = http.StatusNotFound + case errors.Is(err, bookings.ErrInvalidBooking): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) + + server.router.GET("/v1/public/tenants/:tenantSlug/catalog", func(c *gin.Context) { + response, err := bookingService.Catalog(c.Request.Context(), c.Param("tenantSlug")) if err != nil { status := http.StatusInternalServerError if errors.Is(err, bookings.ErrTenantNotFound) { @@ -541,6 +565,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 { @@ -563,6 +725,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 @@ -746,6 +923,160 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { c.JSON(http.StatusOK, gin.H{"status": "updated"}) }) + // Staff-scoped working hours (per-person availability rules) + protected.GET("/catalog/staff/:staffID/working-hours", func(c *gin.Context) { + response, err := catalogService.ListStaffWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID")) + if err != nil { + status := http.StatusInternalServerError + switch { + case 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, response) + }) + protected.PUT("/catalog/staff/:staffID/working-hours/:dayOfWeek", func(c *gin.Context) { + dayOfWeek, err := strconv.Atoi(c.Param("dayOfWeek")) + if err != nil || dayOfWeek < 0 || dayOfWeek > 6 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_day_of_week"}) + return + } + var request domain.UpdateWorkingHoursRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + err = catalogService.UpdateStaffWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"), dayOfWeek, 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.ErrInvalidBooking): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "updated"}) + }) + + // ============================================ + // CATALOG API - Classes + // ============================================ + protected.GET("/catalog/class-templates", func(c *gin.Context) { + response, err := catalogService.ListClassTemplates(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/class-templates", 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.CreateClassTemplate(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/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 { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassTemplateNotFound): + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) + }) + protected.GET("/catalog/class-sessions", func(c *gin.Context) { + response, err := catalogService.ListClassSessions(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/class-sessions", func(c *gin.Context) { + var request domain.CreateClassSessionRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + response, err := catalogService.CreateClassSession(c.Request.Context(), auth.PrincipalFromContext(c), request) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassTemplateNotFound), errors.Is(err, catalog.ErrLocationNotFound): + 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.DELETE("/catalog/class-sessions/:sessionID", func(c *gin.Context) { + err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID")) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassSessionNotFound): + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) + }) + // ============================================ // CUSTOMER BOOKING MANAGEMENT API (Public) // ============================================ diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index a8c8ac2..3bc02d6 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 } @@ -103,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 } @@ -163,10 +174,38 @@ 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 { + if db.IsExclusionViolation(err) { + return ErrBookingConflict + } 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) @@ -190,10 +229,20 @@ 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) + // 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 +319,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..c3d65b5 --- /dev/null +++ b/apps/backend/internal/bookings/lifecycle.go @@ -0,0 +1,98 @@ +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) + // 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 78b9555..a53cad1 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -58,7 +58,13 @@ func (n *noopNotifier) SendBusinessNotification(ctx context.Context, businessEma return nil } -func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.PublicAvailability, error) { +type AvailabilityFilter struct { + ServiceID string + StaffID string + Date string +} + +func (s *Service) Availability(ctx context.Context, tenantSlug string, filter AvailabilityFilter) (domain.PublicAvailability, error) { tenant, err := s.repo.GetTenantBySlug(ctx, tenantSlug) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -67,27 +73,101 @@ func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.P return domain.PublicAvailability{}, err } + location, err := time.LoadLocation(tenant.Timezone) + if err != nil { + location = time.UTC + } + services, err := s.repo.ListServicesByTenant(ctx, tenant.ID) if err != nil { return domain.PublicAvailability{}, err } + if filter.ServiceID != "" { + found := false + for _, service := range services { + if service.ID == filter.ServiceID { + services = []db.ServiceRecord{service} + found = true + break + } + } + if !found { + return domain.PublicAvailability{}, fmt.Errorf("%w: serviceId is not available for tenant", ErrInvalidBooking) + } + } + + staffRecords, err := s.repo.ListStaffByTenant(ctx, tenant.ID) + if err != nil { + return domain.PublicAvailability{}, err + } + staffNames := make(map[string]string, len(staffRecords)) + for _, staff := range staffRecords { + staffNames[staff.ID] = staff.DisplayName + } + var staffFilter *string + if filter.StaffID != "" { + for _, staff := range staffRecords { + if staff.ID == filter.StaffID && staff.Active { + staffID := staff.ID + staffFilter = &staffID + break + } + } + if staffFilter == nil { + return domain.PublicAvailability{}, fmt.Errorf("%w: staffId is not available for tenant", ErrInvalidBooking) + } + } + rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, tenant.ID) if err != nil { return domain.PublicAvailability{}, err } - windowStart := time.Now().UTC() + + now := time.Now() + windowStart := now.UTC() windowEnd := windowStart.AddDate(0, 0, 7) + var days []time.Time + if filter.Date != "" { + day, err := time.ParseInLocation("2006-01-02", filter.Date, location) + if err != nil { + return domain.PublicAvailability{}, fmt.Errorf("%w: date must be YYYY-MM-DD", ErrInvalidBooking) + } + days = []time.Time{day} + windowStart = day.UTC() + windowEnd = day.AddDate(0, 0, 1).UTC() + } + existingBookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, windowStart, windowEnd) if err != nil { return domain.PublicAvailability{}, err } + blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, tenant.ID, windowStart, windowEnd) + if err != nil { + return domain.PublicAvailability{}, err + } + + if days != nil { + slots := appointmentSlotsForDays(tenant, location, now, days, services, rules, blockedDays, staffNames, existingBookings, staffFilter, 0) + sort.Slice(slots, func(i, j int) bool { return slots[i].StartsAt < slots[j].StartsAt }) + return domain.PublicAvailability{ + TenantSlug: tenant.Slug, + Timezone: tenant.Timezone, + Locale: tenant.Locale, + Slots: slots, + }, nil + } + classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, windowStart, 8) if err != nil { return domain.PublicAvailability{}, err } + previewDays := make([]time.Time, 0, 7) + for offset := 0; offset < 7; offset++ { + previewDays = append(previewDays, now.In(location).AddDate(0, 0, offset)) + } slots := make([]domain.TimeSlot, 0, 8) - slots = append(slots, generateAppointmentSlots(tenant, services, rules, existingBookings)...) + slots = append(slots, appointmentSlotsForDays(tenant, location, now, previewDays, services, rules, blockedDays, staffNames, existingBookings, staffFilter, 4)...) slots = append(slots, generateClassSlots(classSessions, existingBookings)...) sort.Slice(slots, func(i, j int) bool { return slots[i].StartsAt < slots[j].StartsAt }) if len(slots) > 8 { @@ -102,6 +182,51 @@ func (s *Service) Availability(ctx context.Context, tenantSlug string) (domain.P }, nil } +// Catalog returns the public-facing service and staff catalog for a tenant's +// booking page. Only active staff are listed. +func (s *Service) Catalog(ctx context.Context, tenantSlug string) (domain.PublicCatalog, error) { + tenant, err := s.repo.GetTenantBySlug(ctx, tenantSlug) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.PublicCatalog{}, ErrTenantNotFound + } + return domain.PublicCatalog{}, err + } + + services, err := s.repo.ListServicesByTenant(ctx, tenant.ID) + if err != nil { + return domain.PublicCatalog{}, err + } + staffRecords, err := s.repo.ListStaffByTenant(ctx, tenant.ID) + if err != nil { + return domain.PublicCatalog{}, err + } + + catalog := domain.PublicCatalog{ + TenantSlug: tenant.Slug, + Services: make([]domain.PublicService, 0, len(services)), + Staff: make([]domain.PublicStaff, 0, len(staffRecords)), + } + for _, service := range services { + catalog.Services = append(catalog.Services, domain.PublicService{ + ID: service.ID, + Name: service.Name, + DurationMinutes: service.DurationMinutes, + PriceCents: service.PriceCents, + }) + } + for _, staff := range staffRecords { + if !staff.Active { + continue + } + catalog.Staff = append(catalog.Staff, domain.PublicStaff{ + ID: staff.ID, + DisplayName: staff.DisplayName, + }) + } + return catalog, nil +} + func (s *Service) Create(ctx context.Context, request domain.CreateBookingRequest) (domain.CreateBookingResponse, error) { tenant, err := s.repo.GetTenantBySlug(ctx, request.TenantSlug) if err != nil { @@ -165,6 +290,9 @@ 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 := validateAppointmentTarget(ctx, s.repo, tenant, request.StaffID, request.LocationID, startsAt, endsAt); err != nil { + return domain.CreateBookingResponse{}, err + } for _, booking := range existing { if booking.Status == "cancelled" { continue @@ -213,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 } @@ -229,15 +360,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 - } + 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) @@ -251,6 +375,57 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques }, nil } +// validateAppointmentTarget ensures the requested appointment references +// 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 validateAppointmentTarget(ctx context.Context, repo db.Repository, tenant db.TenantRecord, staffID, locationID *string, startsAt, endsAt time.Time) error { + if staffID != nil { + 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) + } + return err + } + if staff.TenantID != tenant.ID || !staff.Active { + return fmt.Errorf("%w: staffId is not available for tenant", ErrInvalidBooking) + } + } + if locationID != nil { + 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) + } + return err + } + if location.TenantID != tenant.ID { + return fmt.Errorf("%w: locationId is not available for tenant", ErrInvalidBooking) + } + } + + rules, err := repo.ListAvailabilityRulesByTenant(ctx, tenant.ID) + if err != nil { + return err + } + blockedDays, err := repo.ListBlockedDaysByTenant(ctx, tenant.ID, startsAt.UTC(), endsAt.UTC()) + if err != nil { + return err + } + location, err := time.LoadLocation(tenant.Timezone) + if err != nil { + location = time.UTC + } + if !fitsAvailabilityWindow(rules, location, staffID, startsAt, endsAt) { + return fmt.Errorf("%w: requested time is outside bookable hours", ErrInvalidBooking) + } + if blockedAt(blockedDays, staffID, startsAt.UTC(), endsAt.UTC()) { + return fmt.Errorf("%w: requested time falls on a blocked day", ErrInvalidBooking) + } + return nil +} + func (s *Service) manageURL(booking db.CreatedBooking) string { return fmt.Sprintf("%s/manage/%s?token=%s", s.frontendURL, booking.Reference, booking.ManageToken) } @@ -355,6 +530,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 +553,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 } @@ -381,6 +565,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 @@ -408,6 +600,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,11 +616,13 @@ 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)}, - {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, "%"), + dashboardKPI("new_clients", "New clients", metrics.NewClientsCount, previousMetrics.NewClientsCount, ""), }, UpcomingBookings: upcoming, AllBookings: allBookings, @@ -428,6 +631,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{ @@ -445,68 +670,86 @@ func trackingStatus(repo db.Repository, ctx context.Context, tenant db.TenantRec return domain.TrackingStatus{Provider: "umami", Connected: true, SiteID: brand.UmamiSiteID, Message: "Umami tracking is connected."} } -func generateAppointmentSlots( +// appointmentSlotsForDays generates appointment slots for the given tenant-local +// days. When staffFilter is set it acts as the customer's staff preference: +// tenant-wide rules (staffID nil) still apply and produce slots assigned to the +// preferred staff member, while rules scoped to another staff member are +// skipped. capPerService limits total slots per service across all days; a cap +// of 0 means unlimited. +func appointmentSlotsForDays( tenant db.TenantRecord, + location *time.Location, + now time.Time, + days []time.Time, services []db.ServiceRecord, rules []db.AvailabilityRuleRecord, + blockedDays []db.BlockedDayRecord, + staffNames map[string]string, existing []db.BookingRecord, + staffFilter *string, + capPerService int, ) []domain.TimeSlot { if len(services) == 0 || len(rules) == 0 { return nil } - location, err := time.LoadLocation(tenant.Timezone) - if err != nil { - location = time.UTC - } - - now := time.Now().In(location) var slots []domain.TimeSlot - for _, service := range services { serviceSlots := 0 - for dayOffset := 0; dayOffset < 7 && serviceSlots < 4; dayOffset++ { - day := now.AddDate(0, 0, dayOffset) + for _, day := range days { + if capPerService > 0 && serviceSlots >= capPerService { + break + } for _, rule := range rules { if int(day.Weekday()) != rule.DayOfWeek { continue } - - startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location) - if err != nil { - continue - } - endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location) - if err != nil { + if staffFilter != nil && rule.StaffID != nil && *rule.StaffID != *staffFilter { continue } - windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location) - windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location) + windowStart, windowEnd, ok := ruleWindow(day, rule, location) + if !ok { + continue + } + + slotStaffID := rule.StaffID + if staffFilter != nil { + slotStaffID = staffFilter + } step := time.Duration(service.DurationMinutes+service.BufferAfterMinutes) * time.Minute duration := time.Duration(service.DurationMinutes) * time.Minute - for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(step) { + for slotStart := windowStart; !slotStart.Add(duration).After(windowEnd); slotStart = slotStart.Add(step) { if slotStart.Before(now.Add(2 * time.Hour)) { continue } slotEnd := slotStart.Add(duration) - if collides(existing, rule.StaffID, nil, slotStart.UTC(), slotEnd.UTC()) { + if collides(existing, slotStaffID, nil, slotStart.UTC(), slotEnd.UTC()) { + continue + } + if blockedAt(blockedDays, slotStaffID, slotStart.UTC(), slotEnd.UTC()) { continue } serviceID := service.ID - slots = append(slots, domain.TimeSlot{ + slot := domain.TimeSlot{ ServiceID: &serviceID, - StaffID: rule.StaffID, + StaffID: slotStaffID, StartsAt: slotStart.UTC().Format(time.RFC3339), EndsAt: slotEnd.UTC().Format(time.RFC3339), Mode: "appointment", Label: service.Name, - }) + } + if slotStaffID != nil { + if name, ok := staffNames[*slotStaffID]; ok { + slot.StaffName = &name + } + } + slots = append(slots, slot) serviceSlots++ - if serviceSlots >= 4 { + if capPerService > 0 && serviceSlots >= capPerService { break } } @@ -517,6 +760,60 @@ func generateAppointmentSlots( return slots } +// ruleWindow resolves a weekly availability rule to concrete start/end times on +// the given day in the tenant's timezone. +func ruleWindow(day time.Time, rule db.AvailabilityRuleRecord, location *time.Location) (time.Time, time.Time, bool) { + startsLocal, err := time.ParseInLocation("15:04:05", rule.StartsLocal, location) + if err != nil { + return time.Time{}, time.Time{}, false + } + endsLocal, err := time.ParseInLocation("15:04:05", rule.EndsLocal, location) + if err != nil { + return time.Time{}, time.Time{}, false + } + windowStart := time.Date(day.Year(), day.Month(), day.Day(), startsLocal.Hour(), startsLocal.Minute(), 0, 0, location) + windowEnd := time.Date(day.Year(), day.Month(), day.Day(), endsLocal.Hour(), endsLocal.Minute(), 0, 0, location) + return windowStart, windowEnd, true +} + +// blockedAt reports whether a tenant-wide blocked day or one scoped to the +// given staff member overlaps the requested window. +func blockedAt(blockedDays []db.BlockedDayRecord, staffID *string, startsAt, endsAt time.Time) bool { + for _, blocked := range blockedDays { + if !(blocked.StartsAt.Before(endsAt) && blocked.EndsAt.After(startsAt)) { + continue + } + if blocked.StaffID == nil || (staffID != nil && *blocked.StaffID == *staffID) { + return true + } + } + return false +} + +// fitsAvailabilityWindow reports whether the requested window falls inside an +// availability rule the booking could claim: a tenant-wide rule, or one scoped +// to the requested staff member. +func fitsAvailabilityWindow(rules []db.AvailabilityRuleRecord, location *time.Location, staffID *string, startsAt, endsAt time.Time) bool { + day := startsAt.In(location) + weekday := int(day.Weekday()) + for _, rule := range rules { + if rule.DayOfWeek != weekday { + continue + } + if rule.StaffID != nil && (staffID == nil || *rule.StaffID != *staffID) { + continue + } + windowStart, windowEnd, ok := ruleWindow(day, rule, location) + if !ok { + continue + } + if !day.Before(windowStart) && !endsAt.In(location).After(windowEnd) { + return true + } + } + return false +} + func generateClassSlots(classSessions []db.ClassSessionRecord, existing []db.BookingRecord) []domain.TimeSlot { slots := make([]domain.TimeSlot, 0, len(classSessions)) for _, session := range classSessions { @@ -618,3 +915,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 5e29390..24488cf 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" @@ -14,7 +16,7 @@ func TestCreateAppointmentRejectsConflict(t *testing.T) { repo := db.NewMemoryRepository() service := NewService(repo, nil, "http://localhost:3000") - availability, err := service.Availability(context.Background(), "studio-atelier") + availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{}) if err != nil { t.Fatalf("availability: %v", err) } @@ -71,7 +73,7 @@ func TestCreateClassFallsBackToWaitlistWhenCapacityReached(t *testing.T) { repo := db.NewMemoryRepository() service := NewService(repo, nil, "http://localhost:3000") - availability, err := service.Availability(context.Background(), "studio-atelier") + availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{}) if err != nil { t.Fatalf("availability: %v", err) } @@ -164,7 +166,7 @@ func TestAvailabilityGeneratesUpcomingSlots(t *testing.T) { repo := db.NewMemoryRepository() service := NewService(repo, nil, "http://localhost:3000") - availability, err := service.Availability(context.Background(), "studio-atelier") + availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{}) if err != nil { t.Fatalf("availability: %v", err) } @@ -187,7 +189,7 @@ func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) { repo := db.NewMemoryRepository() service := NewService(repo, nil, "http://localhost:3000") - availability, err := service.Availability(context.Background(), "studio-atelier") + availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: nextRuleDay(t, 2)}) if err != nil { t.Fatalf("availability: %v", err) } @@ -211,8 +213,8 @@ func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) { LocationID: appointment.LocationID, CustomerName: "Reminder", CustomerEmail: "reminder@example.com", - StartsAt: time.Now().UTC().Add(30 * time.Hour).Format(time.RFC3339), - EndsAt: time.Now().UTC().Add(31 * time.Hour).Format(time.RFC3339), + StartsAt: appointment.StartsAt, + EndsAt: appointment.EndsAt, }) if err != nil { t.Fatalf("create: %v", err) @@ -226,3 +228,541 @@ func TestCreateSchedulesReminderJobForUpcomingAppointment(t *testing.T) { t.Fatal("expected reminder job to be scheduled") } } + +// nextRuleDay returns the first day at least minOffset days out whose weekday +// has a seeded availability rule (Mon–Fri), formatted YYYY-MM-DD. +func nextRuleDay(t *testing.T, minOffset int) string { + t.Helper() + for offset := minOffset; offset < minOffset+7; offset++ { + day := time.Now().AddDate(0, 0, offset) + if day.Weekday() >= time.Monday && day.Weekday() <= time.Friday { + return day.Format("2006-01-02") + } + } + t.Fatal("no rule day found") + return "" +} + +func TestAvailabilityDateFilterReturnsFullDaySlots(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + + day := nextRuleDay(t, 2) + availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: day}) + if err != nil { + t.Fatalf("availability: %v", err) + } + if len(availability.Slots) == 0 { + t.Fatal("expected day slots") + } + tenantLocation, err := time.LoadLocation(availability.Timezone) + if err != nil { + t.Fatalf("load timezone: %v", err) + } + for _, slot := range availability.Slots { + if slot.Mode != "appointment" { + t.Fatalf("expected only appointment slots, got %s", slot.Mode) + } + startsAt, err := time.Parse(time.RFC3339, slot.StartsAt) + if err != nil { + t.Fatalf("parse startsAt: %v", err) + } + if startsAt.In(tenantLocation).Format("2006-01-02") != day { + t.Fatalf("slot %s outside requested day %s", slot.StartsAt, day) + } + if slot.StaffName == nil || *slot.StaffName == "" { + t.Fatal("expected staffName on slot") + } + } +} + +func TestAvailabilityHonorsTenantWideBlockedDay(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + + day := nextRuleDay(t, 2) + dayStart, err := time.Parse("2006-01-02", day) + if err != nil { + t.Fatalf("parse day: %v", err) + } + if _, err := repo.CreateBlockedDay(context.Background(), db.CreateBlockedDayParams{ + TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", + StartsAt: dayStart.UTC(), + EndsAt: dayStart.Add(26 * time.Hour).UTC(), + Kind: "blocked", + Reason: "holiday", + }); err != nil { + t.Fatalf("create blocked day: %v", err) + } + + availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{Date: day}) + if err != nil { + t.Fatalf("availability: %v", err) + } + if len(availability.Slots) != 0 { + t.Fatalf("expected no slots on blocked day, got %d", len(availability.Slots)) + } +} + +func TestAvailabilityStaffFilterValidatesTenantStaff(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + foreignStaff := "22222222-2222-2222-2222-222222222222" + + _, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{StaffID: foreignStaff}) + if !errors.Is(err, ErrInvalidBooking) { + t.Fatalf("expected ErrInvalidBooking, got %v", err) + } +} + +func TestCatalogReturnsServicesAndActiveStaff(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + + catalog, err := service.Catalog(context.Background(), "studio-atelier") + if err != nil { + t.Fatalf("catalog: %v", err) + } + if len(catalog.Services) == 0 { + t.Fatal("expected services") + } + if len(catalog.Staff) == 0 { + t.Fatal("expected staff") + } + for _, staff := range catalog.Staff { + if staff.DisplayName == "" { + t.Fatal("expected display name") + } + } +} + +func TestCreateAppointmentRejectsTimeOutsideAvailabilityWindow(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + + catalog, err := service.Catalog(context.Background(), "studio-atelier") + if err != nil { + t.Fatalf("catalog: %v", err) + } + serviceID := catalog.Services[0].ID + staffID := catalog.Staff[0].ID + + // 03:00 on a rule day is outside the seeded 09:00–17:00 window. + day := nextRuleDay(t, 2) + startsAt, err := time.Parse(time.RFC3339, day+"T03:00:00Z") + if err != nil { + t.Fatalf("parse startsAt: %v", err) + } + _, err = service.Create(context.Background(), domain.CreateBookingRequest{ + TenantSlug: "studio-atelier", + BookingMode: "appointment", + ServiceID: &serviceID, + StaffID: &staffID, + CustomerName: "Night Owl", + CustomerEmail: "night@example.com", + StartsAt: startsAt.Format(time.RFC3339), + EndsAt: startsAt.Add(time.Hour).Format(time.RFC3339), + }) + if !errors.Is(err, ErrInvalidBooking) { + t.Fatalf("expected ErrInvalidBooking, got %v", err) + } +} + +func TestCreateAppointmentRejectsForeignStaff(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + + availability, err := service.Availability(context.Background(), "studio-atelier", AvailabilityFilter{}) + if err != nil { + t.Fatalf("availability: %v", err) + } + var appointment domain.TimeSlot + for _, slot := range availability.Slots { + if slot.Mode == "appointment" { + appointment = slot + break + } + } + if appointment.StartsAt == "" { + t.Fatal("expected appointment slot") + } + foreignStaff := "22222222-2222-2222-2222-222222222222" + _, err = service.Create(context.Background(), domain.CreateBookingRequest{ + TenantSlug: "studio-atelier", + BookingMode: "appointment", + ServiceID: appointment.ServiceID, + StaffID: &foreignStaff, + CustomerName: "Impersonator", + CustomerEmail: "fake@example.com", + StartsAt: appointment.StartsAt, + EndsAt: appointment.EndsAt, + }) + if !errors.Is(err, ErrInvalidBooking) { + 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) + } +} + +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, 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", + 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; 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) != 5 { + t.Fatalf("expected 5 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) + } + // 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 { + 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) + } +} + +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) + } +} + +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/catalog/service.go b/apps/backend/internal/catalog/service.go index 61f363f..d7b0962 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -11,18 +11,26 @@ 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" ) var ( - ErrLocationNotFound = errors.New("location not found") - ErrBlockedDayNotFound = errors.New("blocked day not found") - ErrCustomerNotFound = errors.New("customer not found") - ErrBookingNotFound = errors.New("booking not found") - ErrInvalidBooking = errors.New("invalid booking request") - ErrBookingConflict = errors.New("booking conflicts with an existing reservation") - ErrTenantNotFound = errors.New("tenant not found") - ErrTenantMembership = errors.New("tenant membership not found") - ErrPlanLimitReached = errors.New("plan limit reached") + ErrLocationNotFound = errors.New("location not found") + 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") + ErrTenantMembership = errors.New("tenant membership not found") + ErrPlanLimitReached = errors.New("plan limit reached") + ErrClassTemplateNotFound = errors.New("class template not found") + ErrClassSessionNotFound = errors.New("class session not found") ) type Service struct { @@ -32,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 } } @@ -39,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} } @@ -183,6 +195,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 // ============================================ @@ -479,18 +795,20 @@ func (s *Service) ListWorkingHours(ctx context.Context, principal domain.Princip return nil, ErrTenantMembership } - records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID) + records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID, nil) if err != nil { return nil, err } hours := make([]domain.WorkingHours, len(records)) for i, rec := range records { + open := hhmm(rec.StartsLocal) + close_ := hhmm(rec.EndsLocal) hours[i] = domain.WorkingHours{ DayOfWeek: rec.DayOfWeek, - Open: rec.StartsLocal, - Close: rec.EndsLocal, - IsOpen: rec.StartsLocal != "" && rec.EndsLocal != "", + Open: open, + Close: close_, + IsOpen: open != "" && close_ != "", } } return hours, nil @@ -516,7 +834,81 @@ func (s *Service) UpdateWorkingHours(ctx context.Context, principal domain.Princ params.EndsLocal = &req.Close } - return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, dayOfWeek, params) + return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, nil, dayOfWeek, params) +} + +// ListStaffWorkingHours returns the per-person schedule for a tenant-owned +// staff member. +func (s *Service) ListStaffWorkingHours(ctx context.Context, principal domain.Principal, staffID string) ([]domain.WorkingHours, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return nil, ErrTenantMembership + } + if err := s.requireTenantStaff(ctx, membership.Tenant.ID, staffID); err != nil { + return nil, err + } + + records, err := s.repo.ListWorkingHoursByTenant(ctx, membership.Tenant.ID, &staffID) + if err != nil { + return nil, err + } + + hours := make([]domain.WorkingHours, len(records)) + for i, rec := range records { + open := hhmm(rec.StartsLocal) + close_ := hhmm(rec.EndsLocal) + hours[i] = domain.WorkingHours{ + DayOfWeek: rec.DayOfWeek, + Open: open, + Close: close_, + IsOpen: open != "" && close_ != "", + } + } + return hours, nil +} + +// UpdateStaffWorkingHours replaces a staff member's availability rule for one +// weekday. The rule directly drives public slot generation for that person. +func (s *Service) UpdateStaffWorkingHours(ctx context.Context, principal domain.Principal, staffID string, dayOfWeek int, req domain.UpdateWorkingHoursRequest) error { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return ErrTenantMembership + } + if err := s.requireTenantStaff(ctx, membership.Tenant.ID, staffID); err != nil { + return err + } + + params := db.UpdateWorkingHoursParams{IsOpen: req.IsOpen} + if req.Open != "" { + if _, err := time.Parse("15:04", req.Open); err != nil { + return fmt.Errorf("%w: open must be HH:MM", ErrInvalidBooking) + } + params.StartsLocal = &req.Open + } + if req.Close != "" { + if _, err := time.Parse("15:04", req.Close); err != nil { + return fmt.Errorf("%w: close must be HH:MM", ErrInvalidBooking) + } + params.EndsLocal = &req.Close + } + + return s.repo.UpdateWorkingHours(ctx, membership.Tenant.ID, &staffID, dayOfWeek, params) +} + +// requireTenantStaff verifies the staff record exists and belongs to the +// tenant, returning a tenant-safe not-found error otherwise. +func (s *Service) requireTenantStaff(ctx context.Context, tenantID, staffID string) error { + staff, err := s.repo.GetStaffByID(ctx, staffID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrStaffNotFound + } + return err + } + if staff.TenantID != tenantID { + return ErrStaffNotFound + } + return nil } // CreateBooking creates a manual booking from the dashboard. The service name @@ -608,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 } @@ -623,3 +1018,399 @@ func (s *Service) CreateBooking(ctx context.Context, principal domain.Principal, Notes: strings.TrimSpace(req.Notes), }, nil } + +// ============================================ +// CLASS TEMPLATE & SESSION MANAGEMENT +// ============================================ + +func (s *Service) ListClassTemplates(ctx context.Context, principal domain.Principal) ([]domain.ClassTemplate, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return nil, ErrTenantMembership + } + + records, err := s.repo.ListClassTemplatesByTenant(ctx, membership.Tenant.ID) + if err != nil { + return nil, err + } + templates := make([]domain.ClassTemplate, len(records)) + for i, rec := range records { + templates[i] = domain.ClassTemplate{ + ID: rec.ID, + TenantID: rec.TenantID, + Title: rec.Title, + DurationMinutes: rec.DurationMinutes, + Capacity: rec.Capacity, + CreatedAt: rec.CreatedAt, + } + } + return templates, nil +} + +func (s *Service) CreateClassTemplate(ctx context.Context, principal domain.Principal, req domain.UpsertClassTemplateRequest) (domain.ClassTemplate, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return domain.ClassTemplate{}, ErrTenantMembership + } + + title, err := validateClassTemplateInput(req) + if err != nil { + return domain.ClassTemplate{}, err + } + + rec, err := s.repo.CreateClassTemplate(ctx, 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 (s *Service) DeleteClassTemplate(ctx context.Context, principal domain.Principal, templateID string) error { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return ErrTenantMembership + } + if err := s.requireTenantClassTemplate(ctx, membership.Tenant.ID, templateID); err != nil { + return err + } + return s.repo.DeleteClassTemplate(ctx, templateID) +} + +// ListClassSessions returns upcoming sessions enriched with location names and +// live booked counts for the management UI. +func (s *Service) ListClassSessions(ctx context.Context, principal domain.Principal) ([]domain.ClassSession, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return nil, ErrTenantMembership + } + tenantID := membership.Tenant.ID + + now := time.Now().UTC() + sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, now, 100) + if err != nil { + return nil, err + } + if len(sessions) == 0 { + return []domain.ClassSession{}, nil + } + + var windowEnd time.Time + for _, session := range sessions { + if session.EndsAt.After(windowEnd) { + windowEnd = session.EndsAt + } + } + bookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenantID, now, windowEnd) + if err != nil { + return nil, err + } + locations, err := s.repo.ListLocationsByTenant(ctx, tenantID) + if err != nil { + return nil, err + } + locationNames := make(map[string]string, len(locations)) + for _, loc := range locations { + locationNames[loc.ID] = loc.Name + } + + out := make([]domain.ClassSession, len(sessions)) + for i, session := range sessions { + booked := 0 + for _, booking := range bookings { + if booking.ClassSessionID != nil && *booking.ClassSessionID == session.ID && + booking.Status != "cancelled" && booking.Status != "waitlisted" { + booked++ + } + } + out[i] = domain.ClassSession{ + ID: session.ID, + TenantID: session.TenantID, + TemplateID: session.TemplateID, + TemplateTitle: session.Title, + LocationID: session.LocationID, + LocationName: locationNames[deref(session.LocationID)], + StartsAt: session.StartsAt, + EndsAt: session.EndsAt, + Capacity: int(session.Capacity), + BookedCount: booked, + } + } + return out, nil +} + +func (s *Service) CreateClassSession(ctx context.Context, principal domain.Principal, req domain.CreateClassSessionRequest) (domain.ClassSession, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return domain.ClassSession{}, ErrTenantMembership + } + tenantID := membership.Tenant.ID + + if err := s.requireTenantClassTemplate(ctx, tenantID, req.TemplateID); err != nil { + return domain.ClassSession{}, err + } + template, err := s.repo.GetClassTemplateByID(ctx, req.TemplateID) + if err != nil { + return domain.ClassSession{}, err + } + + startsAt, err := time.Parse(time.RFC3339, strings.TrimSpace(req.StartsAt)) + if err != nil { + return domain.ClassSession{}, fmt.Errorf("%w: startsAt must be RFC3339", ErrInvalidInput) + } + if !startsAt.After(time.Now()) { + return domain.ClassSession{}, fmt.Errorf("%w: startsAt must be in the future", ErrInvalidInput) + } + + if req.LocationID != nil { + location, err := s.repo.GetLocationByID(ctx, *req.LocationID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ClassSession{}, ErrLocationNotFound + } + return domain.ClassSession{}, err + } + if location.TenantID != tenantID { + return domain.ClassSession{}, ErrLocationNotFound + } + } + + capacity := template.Capacity + if req.Capacity != nil { + if *req.Capacity < 1 || *req.Capacity > 500 { + return domain.ClassSession{}, fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput) + } + capacity = *req.Capacity + } + endsAt := startsAt.Add(time.Duration(template.DurationMinutes) * time.Minute) + + rec, err := s.repo.CreateClassSession(ctx, db.CreateClassSessionParams{ + TenantID: tenantID, + TemplateID: req.TemplateID, + LocationID: req.LocationID, + StartsAt: startsAt.UTC(), + EndsAt: endsAt.UTC(), + Capacity: int32(capacity), + }) + if err != nil { + return domain.ClassSession{}, err + } + return domain.ClassSession{ + ID: rec.ID, + TenantID: rec.TenantID, + TemplateID: rec.TemplateID, + TemplateTitle: rec.Title, + LocationID: rec.LocationID, + StartsAt: rec.StartsAt, + EndsAt: rec.EndsAt, + Capacity: int(rec.Capacity), + }, nil +} + +func (s *Service) DeleteClassSession(ctx context.Context, principal domain.Principal, sessionID string) error { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return ErrTenantMembership + } + session, err := s.repo.GetClassSessionByID(ctx, sessionID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrClassSessionNotFound + } + return err + } + if session.TenantID != membership.Tenant.ID { + return ErrClassSessionNotFound + } + return s.repo.DeleteClassSession(ctx, sessionID) +} + +func (s *Service) requireTenantClassTemplate(ctx context.Context, tenantID, templateID string) error { + template, err := s.repo.GetClassTemplateByID(ctx, templateID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrClassTemplateNotFound + } + return err + } + if template.TenantID != tenantID { + return ErrClassTemplateNotFound + } + return nil +} + +func deref(value *string) string { + if value == nil { + return "" + } + return *value +} + +// hhmm trims a local time value to HH:MM; PostgreSQL returns time columns as +// HH:MM:SS while the API contract uses HH:MM. +func hhmm(value string) string { + if len(value) >= 5 { + return value[:5] + } + 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 + } + _ = s.repo.CancelPendingReminderJobsForBooking(ctx, booking.ID) + + 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) + } +} + +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 new file mode 100644 index 0000000..5f5b45a --- /dev/null +++ b/apps/backend/internal/catalog/service_test.go @@ -0,0 +1,682 @@ +package catalog + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "bookra/apps/backend/internal/bookings" + "bookra/apps/backend/internal/db" + "bookra/apps/backend/internal/domain" +) + +func boolPtr(v bool) *bool { return &v } + +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) + } +} + +const demoStaffID = "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0" + +func TestListStaffWorkingHours(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + + hours, err := svc.ListStaffWorkingHours(context.Background(), owner, demoStaffID) + if err != nil { + t.Fatalf("list staff working hours: %v", err) + } + if len(hours) != 5 { + t.Fatalf("expected 5 seeded weekday rules, got %d", len(hours)) + } + for _, wh := range hours { + if wh.Open != "09:00" || wh.Close != "17:00" { + t.Fatalf("day %d: expected 09:00-17:00, got %s-%s", wh.DayOfWeek, wh.Open, wh.Close) + } + } +} + +func TestUpdateStaffWorkingHours(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + err := svc.UpdateStaffWorkingHours(ctx, owner, demoStaffID, 1, domain.UpdateWorkingHoursRequest{ + IsOpen: boolPtr(true), + Open: "08:00", + Close: "12:00", + }) + if err != nil { + t.Fatalf("update staff hours: %v", err) + } + + hours, err := svc.ListStaffWorkingHours(ctx, owner, demoStaffID) + if err != nil { + t.Fatalf("list staff hours: %v", err) + } + found := false + for _, wh := range hours { + if wh.DayOfWeek == 1 { + found = true + if wh.Open != "08:00" || wh.Close != "12:00" { + t.Fatalf("expected Monday 08:00-12:00, got %s-%s", wh.Open, wh.Close) + } + } + } + if !found { + t.Fatal("Monday rule missing after update") + } + + // Closing a day removes the rule. + err = svc.UpdateStaffWorkingHours(ctx, owner, demoStaffID, 2, domain.UpdateWorkingHoursRequest{ + IsOpen: boolPtr(false), + }) + if err != nil { + t.Fatalf("close staff day: %v", err) + } + hours, _ = svc.ListStaffWorkingHours(ctx, owner, demoStaffID) + for _, wh := range hours { + if wh.DayOfWeek == 2 { + t.Fatal("Tuesday rule should have been removed") + } + } +} + +func TestStaffWorkingHoursRejectsForeignStaff(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + missing := "00000000-0000-0000-0000-000000000099" + if _, err := svc.ListStaffWorkingHours(ctx, owner, missing); !errors.Is(err, ErrStaffNotFound) { + t.Fatalf("expected ErrStaffNotFound, got %v", err) + } + err := svc.UpdateStaffWorkingHours(ctx, owner, missing, 1, domain.UpdateWorkingHoursRequest{ + IsOpen: boolPtr(true), Open: "09:00", Close: "17:00", + }) + if !errors.Is(err, ErrStaffNotFound) { + t.Fatalf("expected ErrStaffNotFound, got %v", err) + } + + err = svc.UpdateStaffWorkingHours(ctx, owner, demoStaffID, 1, domain.UpdateWorkingHoursRequest{ + IsOpen: boolPtr(true), Open: "9am", Close: "17:00", + }) + if !errors.Is(err, ErrInvalidBooking) { + t.Fatalf("expected ErrInvalidBooking for bad time, got %v", err) + } +} + +func TestClassTemplateCRUD(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + templates, err := svc.ListClassTemplates(ctx, owner) + if err != nil { + t.Fatalf("list templates: %v", err) + } + if len(templates) != 1 || templates[0].Title != "Small group mobility class" { + t.Fatalf("expected seeded template, got %+v", templates) + } + + created, err := svc.CreateClassTemplate(ctx, owner, domain.UpsertClassTemplateRequest{ + Title: " Yoga Basics ", + DurationMinutes: 45, + Capacity: 8, + }) + if err != nil { + t.Fatalf("create template: %v", err) + } + if created.Title != "Yoga Basics" { + t.Fatalf("expected trimmed title, got %q", created.Title) + } + + if err := svc.DeleteClassTemplate(ctx, owner, created.ID); err != nil { + t.Fatalf("delete template: %v", err) + } + templates, _ = svc.ListClassTemplates(ctx, owner) + if len(templates) != 1 { + t.Fatalf("expected 1 template after delete, got %d", len(templates)) + } +} + +func TestClassTemplateValidation(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + cases := []domain.UpsertClassTemplateRequest{ + {Title: " ", DurationMinutes: 60, Capacity: 4}, + {Title: "X", DurationMinutes: 0, Capacity: 4}, + {Title: "X", DurationMinutes: 60, Capacity: 0}, + } + for i, req := range cases { + if _, err := svc.CreateClassTemplate(ctx, owner, req); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("case %d: expected ErrInvalidInput, got %v", i, err) + } + } + + missing := "00000000-0000-0000-0000-000000000099" + if err := svc.DeleteClassTemplate(ctx, owner, missing); !errors.Is(err, ErrClassTemplateNotFound) { + t.Fatalf("expected ErrClassTemplateNotFound, got %v", err) + } +} + +func TestDeleteClassTemplateCascadesSessions(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + if err := svc.DeleteClassTemplate(ctx, owner, "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7"); err != nil { + t.Fatalf("delete template: %v", err) + } + sessions, err := svc.ListClassSessions(ctx, owner) + if err != nil { + t.Fatalf("list sessions: %v", err) + } + if len(sessions) != 0 { + t.Fatalf("expected sessions to cascade-delete, got %d", len(sessions)) + } +} + +func TestListClassSessionsEnrichment(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + + sessions, err := svc.ListClassSessions(context.Background(), owner) + if err != nil { + t.Fatalf("list sessions: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("expected 1 seeded session, got %d", len(sessions)) + } + s := sessions[0] + if s.TemplateTitle != "Small group mobility class" { + t.Fatalf("expected template title, got %q", s.TemplateTitle) + } + if s.LocationName != "Main Studio" { + t.Fatalf("expected location name, got %q", s.LocationName) + } + if s.Capacity != 4 || s.BookedCount != 0 { + t.Fatalf("expected capacity 4 booked 0, got %d/%d", s.Capacity, s.BookedCount) + } +} + +func TestCreateClassSession(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + startsAt := time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339) + cap := 6 + created, err := svc.CreateClassSession(ctx, owner, domain.CreateClassSessionRequest{ + TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", + StartsAt: startsAt, + Capacity: &cap, + }) + if err != nil { + t.Fatalf("create session: %v", err) + } + if created.Capacity != 6 { + t.Fatalf("expected override capacity 6, got %d", created.Capacity) + } + if !created.EndsAt.Equal(created.StartsAt.Add(60 * time.Minute)) { + t.Fatalf("expected end = start + 60m, got %s -> %s", created.StartsAt, created.EndsAt) + } +} + +func TestCreateClassSessionValidation(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) + past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + template := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7" + badLocation := "00000000-0000-0000-0000-000000000099" + badCap := 0 + + cases := []struct { + name string + req domain.CreateClassSessionRequest + want error + }{ + {"missing template", domain.CreateClassSessionRequest{TemplateID: "00000000-0000-0000-0000-000000000099", StartsAt: future}, ErrClassTemplateNotFound}, + {"bad start format", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: "tomorrow"}, ErrInvalidInput}, + {"past start", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: past}, ErrInvalidInput}, + {"foreign location", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: future, LocationID: &badLocation}, ErrLocationNotFound}, + {"zero capacity", domain.CreateClassSessionRequest{TemplateID: template, StartsAt: future, Capacity: &badCap}, ErrInvalidInput}, + } + for _, tc := range cases { + if _, err := svc.CreateClassSession(ctx, owner, tc.req); !errors.Is(err, tc.want) { + t.Fatalf("%s: expected %v, got %v", tc.name, tc.want, err) + } + } +} + +func TestDeleteClassSessionPreservesBooking(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + // Book the seeded class session via the public booking service. + bookingSvc := bookings.NewService(repo, nil, "") + sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435" + session, err := repo.GetClassSessionByID(ctx, sessionID) + if err != nil { + t.Fatalf("get seeded session: %v", err) + } + _, err = bookingSvc.Create(ctx, domain.CreateBookingRequest{ + TenantSlug: "studio-atelier", + CustomerName: "Kate", + CustomerEmail: "kate@example.com", + BookingMode: "class", + ClassSessionID: &sessionID, + StartsAt: session.StartsAt.UTC().Format(time.RFC3339), + EndsAt: session.EndsAt.UTC().Format(time.RFC3339), + }) + if err != nil { + t.Fatalf("book class session: %v", err) + } + + sessions, _ := svc.ListClassSessions(ctx, owner) + if sessions[0].BookedCount != 1 { + t.Fatalf("expected bookedCount 1, got %d", sessions[0].BookedCount) + } + + if err := svc.DeleteClassSession(ctx, owner, sessionID); err != nil { + t.Fatalf("delete session: %v", err) + } + + // The booking survives but no longer references the session. + bookingsList, err := repo.ListBookingsByTenantBetween(ctx, sessions[0].TenantID, time.Now().Add(-time.Hour), time.Now().Add(200*time.Hour)) + if err != nil { + t.Fatalf("list bookings: %v", err) + } + if len(bookingsList) != 1 { + t.Fatalf("expected booking to survive, got %d", len(bookingsList)) + } + if bookingsList[0].ClassSessionID != nil { + 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) + } +} + +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/bookings.go b/apps/backend/internal/db/bookings.go index 378ead2..88549a0 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -2,14 +2,17 @@ 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) { 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 +41,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID &record.Reference, &record.ManageToken, &record.Notes, + &record.CreatedAt, ); err != nil { return nil, err } @@ -84,6 +88,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) @@ -96,7 +111,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.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 @@ -123,9 +138,11 @@ func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.T &record.ScheduledFor, &record.CustomerName, &record.CustomerEmail, + &record.CustomerPhone, &record.Reference, &record.StartsAt, &record.Status, + &record.BookingStatus, ); err != nil { return nil, err } @@ -167,13 +184,62 @@ 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, + 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( &metrics.BookingsCount, &metrics.CancellationsCount, &metrics.UtilizationPercent, + &metrics.CompletedCount, + &metrics.NewClientsCount, ) 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 + 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 +} + +// 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/internal/db/classes.go b/apps/backend/internal/db/classes.go new file mode 100644 index 0000000..d3e76f0 --- /dev/null +++ b/apps/backend/internal/db/classes.go @@ -0,0 +1,133 @@ +package db + +import ( + "context" +) + +const classTemplateColumns = "id, tenant_id, title, duration_minutes, capacity, created_at" +const classSessionColumns = "cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity" + +func (r *PGRepository) ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error) { + rows, err := r.pool.Query(ctx, ` + SELECT `+classTemplateColumns+` + FROM class_templates + WHERE tenant_id = $1 + ORDER BY created_at ASC + `, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []ClassTemplateRecord + for rows.Next() { + var record ClassTemplateRecord + if err := rows.Scan( + &record.ID, + &record.TenantID, + &record.Title, + &record.DurationMinutes, + &record.Capacity, + &record.CreatedAt, + ); err != nil { + return nil, err + } + records = append(records, record) + } + return records, rows.Err() +} + +func (r *PGRepository) GetClassTemplateByID(ctx context.Context, templateID string) (ClassTemplateRecord, error) { + var record ClassTemplateRecord + err := r.pool.QueryRow(ctx, ` + SELECT `+classTemplateColumns+` + FROM class_templates + WHERE id = $1 + `, templateID).Scan( + &record.ID, + &record.TenantID, + &record.Title, + &record.DurationMinutes, + &record.Capacity, + &record.CreatedAt, + ) + return record, err +} + +func (r *PGRepository) CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) { + var record ClassTemplateRecord + err := r.pool.QueryRow(ctx, ` + INSERT INTO class_templates (tenant_id, title, duration_minutes, capacity) + VALUES ($1, $2, $3, $4) + RETURNING `+classTemplateColumns+` + `, params.TenantID, 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) 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 +} + +func (r *PGRepository) GetClassSessionByID(ctx context.Context, sessionID string) (ClassSessionRecord, error) { + var record ClassSessionRecord + err := r.pool.QueryRow(ctx, ` + SELECT `+classSessionColumns+` + FROM class_sessions cs + INNER JOIN class_templates ct ON ct.id = cs.template_id + WHERE cs.id = $1 + `, sessionID).Scan( + &record.ID, + &record.TenantID, + &record.TemplateID, + &record.LocationID, + &record.Title, + &record.StartsAt, + &record.EndsAt, + &record.Capacity, + ) + return record, err +} + +func (r *PGRepository) CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) { + var record ClassSessionRecord + err := r.pool.QueryRow(ctx, ` + INSERT INTO class_sessions (tenant_id, template_id, location_id, starts_at, ends_at, capacity) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `, params.TenantID, params.TemplateID, params.LocationID, params.StartsAt, params.EndsAt, params.Capacity).Scan(&record.ID) + if err != nil { + return record, err + } + return r.GetClassSessionByID(ctx, record.ID) +} + +func (r *PGRepository) DeleteClassSession(ctx context.Context, sessionID string) error { + _, err := r.pool.Exec(ctx, `DELETE FROM class_sessions WHERE id = $1`, sessionID) + return err +} diff --git a/apps/backend/internal/db/locations.go b/apps/backend/internal/db/locations.go index 47e523a..0fa4c10 100644 --- a/apps/backend/internal/db/locations.go +++ b/apps/backend/internal/db/locations.go @@ -122,13 +122,24 @@ func (r *PGRepository) DeleteBlockedDay(ctx context.Context, blockedDayID string return err } -func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) { - rows, err := r.pool.Query(ctx, ` +func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) { + query := ` SELECT tenant_id, staff_id, day_of_week, starts_local::text, ends_local::text FROM availability_rules WHERE tenant_id = $1 AND staff_id IS NULL ORDER BY day_of_week - `, tenantID) + ` + args := []any{tenantID} + if staffID != nil { + query = ` + SELECT tenant_id, staff_id, day_of_week, starts_local::text, ends_local::text + FROM availability_rules + WHERE tenant_id = $1 AND staff_id = $2 + ORDER BY day_of_week + ` + args = append(args, *staffID) + } + rows, err := r.pool.Query(ctx, query, args...) if err != nil { return nil, err } @@ -145,12 +156,22 @@ func (r *PGRepository) ListWorkingHoursByTenant(ctx context.Context, tenantID st return records, rows.Err() } -func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error { - if params.IsOpen != nil && !*params.IsOpen { - _, err := r.pool.Exec(ctx, ` +func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error { + deleteQuery := ` + DELETE FROM availability_rules + WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL + ` + args := []any{tenantID, dayOfWeek} + if staffID != nil { + deleteQuery = ` DELETE FROM availability_rules - WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL - `, tenantID, dayOfWeek) + WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id = $3 + ` + args = append(args, *staffID) + } + + if params.IsOpen != nil && !*params.IsOpen { + _, err := r.pool.Exec(ctx, deleteQuery, args...) return err } @@ -159,17 +180,14 @@ func (r *PGRepository) UpdateWorkingHours(ctx context.Context, tenantID string, } // availability_rules has no unique constraint on (tenant, day): replace the - // tenant-level row so repeated updates stay idempotent. - if _, err := r.pool.Exec(ctx, ` - DELETE FROM availability_rules - WHERE tenant_id = $1 AND day_of_week = $2 AND staff_id IS NULL - `, tenantID, dayOfWeek); err != nil { + // scoped row so repeated updates stay idempotent. + if _, err := r.pool.Exec(ctx, deleteQuery, args...); err != nil { return err } _, err := r.pool.Exec(ctx, ` INSERT INTO availability_rules (id, tenant_id, staff_id, day_of_week, starts_local, ends_local) - VALUES (gen_random_uuid(), $1, NULL, $2, $3::time, $4::time) - `, tenantID, dayOfWeek, *params.StartsLocal, *params.EndsLocal) + VALUES (gen_random_uuid(), $1, $2, $3, $4::time, $5::time) + `, tenantID, staffID, dayOfWeek, *params.StartsLocal, *params.EndsLocal) return err } diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 14c6d0a..d14e089 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -26,12 +26,23 @@ type Repository interface { ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) + GetClassSessionByID(ctx context.Context, sessionID string) (ClassSessionRecord, error) + CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) + DeleteClassSession(ctx context.Context, sessionID string) error + 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) 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 + 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) @@ -65,6 +76,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) @@ -83,12 +107,14 @@ 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 // Working Hours - ListWorkingHoursByTenant(ctx context.Context, tenantID string) ([]WorkingHoursRecord, error) - UpdateWorkingHours(ctx context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error + ListWorkingHoursByTenant(ctx context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) + UpdateWorkingHours(ctx context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error // Email templates ListEmailTemplates(ctx context.Context, tenantID string) ([]EmailTemplateRecord, error) @@ -225,6 +251,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 { @@ -236,6 +306,22 @@ type AvailabilityRuleRecord struct { EndsLocal string } +type ClassTemplateRecord struct { + ID string + TenantID string + Title string + DurationMinutes int + Capacity int + CreatedAt time.Time +} + +type CreateClassTemplateParams struct { + TenantID string + Title string + DurationMinutes int + Capacity int +} + type ClassSessionRecord struct { ID string TenantID string @@ -247,6 +333,15 @@ type ClassSessionRecord struct { Capacity int32 } +type CreateClassSessionParams struct { + TenantID string + TemplateID string + LocationID *string + StartsAt time.Time + EndsAt time.Time + Capacity int32 +} + type BookingRecord struct { ID string TenantID string @@ -263,6 +358,7 @@ type BookingRecord struct { Reference string ManageToken string Notes string + CreatedAt time.Time } type CreateBookingParams struct { @@ -302,6 +398,8 @@ type DashboardMetrics struct { BookingsCount int CancellationsCount int UtilizationPercent int + CompletedCount int + NewClientsCount int } type ReminderJobParams struct { @@ -322,9 +420,11 @@ type ReminderJobRecord struct { ScheduledFor time.Time CustomerName string CustomerEmail string + CustomerPhone string Reference string StartsAt time.Time Status string + BookingStatus string } type NotificationDeliveryLogParams struct { @@ -630,11 +730,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 } @@ -662,6 +775,7 @@ type MemoryRepository struct { brand BrandProfileRecord services []ServiceRecord rules []AvailabilityRuleRecord + classTemplates []ClassTemplateRecord classSessions []ClassSessionRecord bookings []BookingRecord waitlistSize int @@ -670,6 +784,7 @@ type MemoryRepository struct { billingSnapshot BillingSnapshotRecord recordedEvents map[string]struct{} locations []LocationRecord + staff []StaffRecord blockedDays []BlockedDayRecord customers []CustomerRecord workingHours []WorkingHoursRecord @@ -732,6 +847,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{ @@ -741,6 +867,9 @@ func NewMemoryRepository() *MemoryRepository { {ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 4, StartsLocal: "09:00:00", EndsLocal: "17:00:00"}, {ID: uuid.NewString(), TenantID: tenantID, StaffID: &staffID, DayOfWeek: 5, StartsLocal: "09:00:00", EndsLocal: "17:00:00"}, }, + classTemplates: []ClassTemplateRecord{ + {ID: templateID, TenantID: tenantID, Title: "Small group mobility class", DurationMinutes: 60, Capacity: 4, CreatedAt: now}, + }, classSessions: []ClassSessionRecord{ { ID: sessionID, @@ -857,6 +986,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 +1067,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 @@ -962,6 +1239,109 @@ func (r *MemoryRepository) ListClassSessionsByTenant(_ context.Context, tenantID return out, nil } +func (r *MemoryRepository) GetClassSessionByID(_ context.Context, sessionID string) (ClassSessionRecord, error) { + for _, session := range r.classSessions { + if session.ID == sessionID { + return session, nil + } + } + return ClassSessionRecord{}, pgx.ErrNoRows +} + +func (r *MemoryRepository) CreateClassSession(_ context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) { + record := ClassSessionRecord{ + ID: uuid.NewString(), + TenantID: params.TenantID, + TemplateID: params.TemplateID, + LocationID: params.LocationID, + StartsAt: params.StartsAt, + EndsAt: params.EndsAt, + Capacity: params.Capacity, + } + for _, template := range r.classTemplates { + if template.ID == params.TemplateID { + record.Title = template.Title + break + } + } + r.classSessions = append(r.classSessions, record) + return record, nil +} + +func (r *MemoryRepository) DeleteClassSession(_ context.Context, sessionID string) error { + for i, session := range r.classSessions { + if session.ID == sessionID { + r.classSessions = append(r.classSessions[:i], r.classSessions[i+1:]...) + for j := range r.bookings { + if r.bookings[j].ClassSessionID != nil && *r.bookings[j].ClassSessionID == sessionID { + r.bookings[j].ClassSessionID = nil + } + } + return nil + } + } + return pgx.ErrNoRows +} + +func (r *MemoryRepository) ListClassTemplatesByTenant(_ context.Context, tenantID string) ([]ClassTemplateRecord, error) { + if tenantID != r.tenant.ID { + return nil, nil + } + return append([]ClassTemplateRecord(nil), r.classTemplates...), nil +} + +func (r *MemoryRepository) GetClassTemplateByID(_ context.Context, templateID string) (ClassTemplateRecord, error) { + for _, template := range r.classTemplates { + if template.ID == templateID { + return template, nil + } + } + return ClassTemplateRecord{}, pgx.ErrNoRows +} + +func (r *MemoryRepository) CreateClassTemplate(_ context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) { + record := ClassTemplateRecord{ + ID: uuid.NewString(), + TenantID: params.TenantID, + Title: params.Title, + DurationMinutes: params.DurationMinutes, + Capacity: params.Capacity, + CreatedAt: time.Now().UTC(), + } + r.classTemplates = append(r.classTemplates, record) + 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 { + r.classTemplates = append(r.classTemplates[:i], r.classTemplates[i+1:]...) + // class_sessions.template_id has ON DELETE CASCADE + var remaining []ClassSessionRecord + for _, session := range r.classSessions { + if session.TemplateID != templateID { + remaining = append(remaining, session) + } + } + r.classSessions = remaining + return nil + } + } + return pgx.ErrNoRows +} + func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) { var out []BookingRecord for _, booking := range r.bookings { @@ -999,6 +1379,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 } @@ -1008,17 +1389,26 @@ 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() 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 } } @@ -1033,6 +1423,7 @@ func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJ ScheduledFor: params.ScheduledFor, CustomerName: customerName, CustomerEmail: customerEmail, + CustomerPhone: customerPhone, Reference: reference, StartsAt: startsAt, Status: "pending", @@ -1044,6 +1435,13 @@ 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 + job.CustomerPhone = booking.CustomerPhone + break + } + } out = append(out, job) } } @@ -1064,6 +1462,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 @@ -1084,6 +1503,24 @@ func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID strin } if booking.Status == "confirmed" { metrics.UtilizationPercent++ + if booking.EndsAt.Before(time.Now().UTC()) { + metrics.CompletedCount++ + } + } + } + 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 { @@ -1412,6 +1849,36 @@ 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) 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 { @@ -1482,29 +1949,114 @@ func (r *MemoryRepository) UpsertEmailTemplate(_ context.Context, params EmailTe // WORKING HOURS METHODS - MEMORY REPOSITORY // ============================================ -func (r *MemoryRepository) ListWorkingHoursByTenant(_ context.Context, tenantID string) ([]WorkingHoursRecord, error) { +// ListWorkingHoursByTenant returns tenant-wide hours from the dedicated +// working-hours store, and staff-scoped hours from the availability rules that +// slot generation consumes — mirroring PostgreSQL where both live in +// availability_rules. +func (r *MemoryRepository) ListWorkingHoursByTenant(_ context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) { if tenantID != r.tenant.ID { return nil, pgx.ErrNoRows } - return r.workingHours, nil + var out []WorkingHoursRecord + if staffID == nil { + for _, wh := range r.workingHours { + if wh.TenantID == tenantID && wh.StaffID == nil { + out = append(out, wh) + } + } + return out, nil + } + for _, rule := range r.rules { + if rule.TenantID == tenantID && rule.StaffID != nil && *rule.StaffID == *staffID { + out = append(out, WorkingHoursRecord{ + TenantID: rule.TenantID, + StaffID: rule.StaffID, + DayOfWeek: rule.DayOfWeek, + StartsLocal: rule.StartsLocal, + EndsLocal: rule.EndsLocal, + }) + } + } + return out, nil } -func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string, dayOfWeek int, params UpdateWorkingHoursParams) error { +func staffIDPtrEqual(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error { if tenantID != r.tenant.ID { return pgx.ErrNoRows } + if staffID != nil { + return r.updateStaffWorkingHours(tenantID, staffID, dayOfWeek, params) + } + if params.IsOpen != nil && !*params.IsOpen { + filtered := r.workingHours[:0] + for _, wh := range r.workingHours { + if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil { + continue + } + filtered = append(filtered, wh) + } + r.workingHours = filtered + return nil + } + if params.StartsLocal == nil || params.EndsLocal == nil { + return nil + } for i, wh := range r.workingHours { - if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek { - if params.StartsLocal != nil { - r.workingHours[i].StartsLocal = *params.StartsLocal - } - if params.EndsLocal != nil { - r.workingHours[i].EndsLocal = *params.EndsLocal - } + if wh.TenantID == tenantID && wh.DayOfWeek == dayOfWeek && wh.StaffID == nil { + r.workingHours[i].StartsLocal = *params.StartsLocal + r.workingHours[i].EndsLocal = *params.EndsLocal return nil } } - return pgx.ErrNoRows + r.workingHours = append(r.workingHours, WorkingHoursRecord{ + TenantID: tenantID, + DayOfWeek: dayOfWeek, + StartsLocal: *params.StartsLocal, + EndsLocal: *params.EndsLocal, + }) + return nil +} + +// updateStaffWorkingHours replaces the staff-scoped availability rule for a +// day so edits actually change generated slots, matching PostgreSQL semantics. +func (r *MemoryRepository) updateStaffWorkingHours(tenantID string, staffID *string, dayOfWeek int, params UpdateWorkingHoursParams) error { + filtered := r.rules[:0] + for _, rule := range r.rules { + if rule.TenantID == tenantID && rule.DayOfWeek == dayOfWeek && rule.StaffID != nil && *rule.StaffID == *staffID { + continue + } + filtered = append(filtered, rule) + } + r.rules = filtered + if params.IsOpen != nil && !*params.IsOpen { + return nil + } + if params.StartsLocal == nil || params.EndsLocal == nil { + return nil + } + r.rules = append(r.rules, AvailabilityRuleRecord{ + ID: uuid.NewString(), + TenantID: tenantID, + StaffID: staffID, + DayOfWeek: dayOfWeek, + StartsLocal: normalizeLocalTime(*params.StartsLocal), + EndsLocal: normalizeLocalTime(*params.EndsLocal), + }) + return nil +} + +func normalizeLocalTime(value string) string { + if len(value) == 5 { + return value + ":00" + } + return value } // Auth methods for MemoryRepository 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..91a9b5d 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 { @@ -114,6 +116,7 @@ type TimeSlot struct { ServiceID *string `json:"serviceId,omitempty"` ClassSessionID *string `json:"classSessionId,omitempty"` StaffID *string `json:"staffId,omitempty"` + StaffName *string `json:"staffName,omitempty"` LocationID *string `json:"locationId,omitempty"` StartsAt string `json:"startsAt"` EndsAt string `json:"endsAt"` @@ -129,6 +132,24 @@ type PublicAvailability struct { Slots []TimeSlot `json:"slots"` } +type PublicService struct { + ID string `json:"id"` + Name string `json:"name"` + DurationMinutes int `json:"durationMinutes"` + PriceCents int `json:"priceCents"` +} + +type PublicStaff struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` +} + +type PublicCatalog struct { + TenantSlug string `json:"tenantSlug"` + Services []PublicService `json:"services"` + Staff []PublicStaff `json:"staff"` +} + type CreateBookingRequest struct { TenantSlug string `json:"tenantSlug"` BookingMode string `json:"bookingMode"` @@ -230,6 +251,7 @@ type DispatchReminderJobsResponse struct { ProcessedCount int `json:"processedCount"` SentCount int `json:"sentCount"` FailedCount int `json:"failedCount"` + SkippedCount int `json:"skippedCount"` } // ============================================ @@ -267,6 +289,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 // ============================================ @@ -293,6 +371,45 @@ type UpdateBlockedDayRequest struct { Type string `json:"type,omitempty"` } +// ============================================ +// CLASS MODELS +// ============================================ + +type ClassTemplate struct { + ID string `json:"id"` + TenantID string `json:"tenantId"` + Title string `json:"title"` + DurationMinutes int `json:"durationMinutes"` + Capacity int `json:"capacity"` + CreatedAt time.Time `json:"createdAt"` +} + +type UpsertClassTemplateRequest struct { + Title string `json:"title" binding:"required"` + DurationMinutes int `json:"durationMinutes" binding:"required"` + Capacity int `json:"capacity" binding:"required"` +} + +type ClassSession struct { + ID string `json:"id"` + TenantID string `json:"tenantId"` + TemplateID string `json:"templateId"` + TemplateTitle string `json:"templateTitle"` + LocationID *string `json:"locationId,omitempty"` + LocationName string `json:"locationName,omitempty"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + Capacity int `json:"capacity"` + BookedCount int `json:"bookedCount"` +} + +type CreateClassSessionRequest struct { + TemplateID string `json:"templateId" binding:"required"` + LocationID *string `json:"locationId,omitempty"` + StartsAt string `json:"startsAt" binding:"required"` // RFC3339 + Capacity *int `json:"capacity,omitempty"` +} + // ============================================ // CUSTOMER MODELS // ============================================ @@ -341,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/backend/internal/notifications/service.go b/apps/backend/internal/notifications/service.go index f033ce2..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 @@ -68,6 +76,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 := "" @@ -83,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() @@ -267,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 4178c13..f6f4e5a 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,86 @@ 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) + } +} + +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/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/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index b68df44..462f4ba 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -263,6 +263,27 @@ paths: required: true schema: type: string + - in: query + name: serviceId + required: false + schema: + type: string + format: uuid + description: Restrict appointment slots to a single service + - in: query + name: staffId + required: false + schema: + type: string + format: uuid + description: Restrict appointment slots to a single staff member + - in: query + name: date + required: false + schema: + type: string + format: date + description: Return all slots for a single tenant-local day (YYYY-MM-DD) instead of the capped preview responses: "200": description: Availability for a tenant booking page @@ -270,6 +291,23 @@ paths: application/json: schema: $ref: "#/components/schemas/PublicAvailabilityResponse" + /v1/public/tenants/{tenantSlug}/catalog: + get: + tags: [Public Booking] + operationId: getPublicCatalog + parameters: + - in: path + name: tenantSlug + required: true + schema: + type: string + responses: + "200": + description: Bookable services and active staff for a tenant booking page + content: + application/json: + schema: + $ref: "#/components/schemas/PublicCatalogResponse" /v1/public/bookings: post: tags: [Public Booking] @@ -589,6 +627,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] @@ -612,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] @@ -798,6 +1006,196 @@ paths: description: Working hours updated "400": description: Invalid request + /v1/catalog/staff/{staffID}/working-hours: + get: + tags: [Catalog] + operationId: listStaffWorkingHours + security: + - bearerAuth: [] + parameters: + - in: path + name: staffID + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Staff member's weekly working hours + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WorkingHours" + "404": + description: Staff member not found + /v1/catalog/staff/{staffID}/working-hours/{dayOfWeek}: + put: + tags: [Catalog] + operationId: updateStaffWorkingHours + security: + - bearerAuth: [] + parameters: + - in: path + name: staffID + required: true + schema: + type: string + format: uuid + - in: path + name: dayOfWeek + required: true + schema: + type: integer + minimum: 0 + maximum: 6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateWorkingHoursRequest" + responses: + "200": + description: Staff working hours updated + "400": + description: Invalid request + "404": + description: Staff member not found + /v1/catalog/class-templates: + get: + tags: [Catalog] + operationId: listClassTemplates + security: + - bearerAuth: [] + responses: + "200": + description: Class templates + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ClassTemplate" + post: + tags: [Catalog] + operationId: createClassTemplate + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertClassTemplateRequest" + responses: + "201": + description: Class template created + content: + application/json: + schema: + $ref: "#/components/schemas/ClassTemplate" + "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 + security: + - bearerAuth: [] + parameters: + - in: path + name: templateID + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Class template deleted + "404": + description: Class template not found + /v1/catalog/class-sessions: + get: + tags: [Catalog] + operationId: listClassSessions + security: + - bearerAuth: [] + responses: + "200": + description: Upcoming class sessions + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ClassSession" + post: + tags: [Catalog] + operationId: createClassSession + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateClassSessionRequest" + responses: + "201": + description: Class session created + content: + application/json: + schema: + $ref: "#/components/schemas/ClassSession" + "400": + description: Invalid request + "404": + description: Template or location not found + /v1/catalog/class-sessions/{sessionID}: + delete: + tags: [Catalog] + operationId: deleteClassSession + security: + - bearerAuth: [] + parameters: + - in: path + name: sessionID + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Class session deleted + "404": + description: Class session not found # ---------------- Billing ---------------- /v1/billing/subscription: get: @@ -1045,6 +1443,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 @@ -1106,6 +1518,9 @@ components: type: string format: uuid nullable: true + staffName: + type: string + nullable: true locationId: type: string format: uuid @@ -1139,6 +1554,42 @@ components: type: array items: $ref: "#/components/schemas/TimeSlot" + PublicCatalogResponse: + type: object + required: [tenantSlug, services, staff] + properties: + tenantSlug: + type: string + services: + type: array + items: + $ref: "#/components/schemas/PublicService" + staff: + type: array + items: + $ref: "#/components/schemas/PublicStaff" + PublicService: + type: object + required: [id, name, durationMinutes, priceCents] + properties: + id: + type: string + format: uuid + name: + type: string + durationMinutes: + type: integer + priceCents: + type: integer + PublicStaff: + type: object + required: [id, displayName] + properties: + id: + type: string + format: uuid + displayName: + type: string CreateBookingRequest: type: object required: [tenantSlug, bookingMode, customerName, customerEmail, startsAt, endsAt] @@ -1198,6 +1649,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] @@ -1478,6 +1934,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] @@ -1564,6 +2095,80 @@ components: example: "18:00" isOpen: type: boolean + ClassTemplate: + type: object + required: [id, tenantId, title, durationMinutes, capacity, createdAt] + properties: + id: + type: string + format: uuid + tenantId: + type: string + format: uuid + title: + type: string + durationMinutes: + type: integer + capacity: + type: integer + createdAt: + type: string + format: date-time + UpsertClassTemplateRequest: + type: object + required: [title, durationMinutes, capacity] + properties: + title: + type: string + durationMinutes: + type: integer + capacity: + type: integer + ClassSession: + type: object + required: [id, tenantId, templateId, templateTitle, startsAt, endsAt, capacity, bookedCount] + properties: + id: + type: string + format: uuid + tenantId: + type: string + format: uuid + templateId: + type: string + format: uuid + templateTitle: + type: string + locationId: + type: string + format: uuid + locationName: + type: string + startsAt: + type: string + format: date-time + endsAt: + type: string + format: date-time + capacity: + type: integer + bookedCount: + type: integer + CreateClassSessionRequest: + type: object + required: [templateId, startsAt] + properties: + templateId: + type: string + format: uuid + locationId: + type: string + format: uuid + startsAt: + type: string + format: date-time + capacity: + type: integer PlanEntitlements: type: object required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking] @@ -1711,7 +2316,7 @@ components: maximum: 200 DispatchReminderJobsResponse: type: object - required: [processedCount, sentCount, failedCount] + required: [processedCount, sentCount, failedCount, skippedCount] properties: processedCount: type: integer @@ -1719,3 +2324,5 @@ components: type: integer failedCount: type: integer + skippedCount: + type: integer diff --git a/apps/frontend/src/components/dashboard/icons.tsx b/apps/frontend/src/components/dashboard/icons.tsx index 4247e58..d05fd56 100644 --- a/apps/frontend/src/components/dashboard/icons.tsx +++ b/apps/frontend/src/components/dashboard/icons.tsx @@ -114,6 +114,18 @@ export const PlusIcon = () => ( ); +export const PencilIcon = () => ( + + + +); + +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..314b64d 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", @@ -309,6 +312,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 +329,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", @@ -398,6 +404,59 @@ 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.zone.businessHours": "Celá firma", + "dashboard.classes": "Lekce", + "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", + "dashboard.class.durationMin": "Délka (min)", + "dashboard.class.template": "Šablona", + "dashboard.class.location": "Místo", + "dashboard.class.noLocation": "Bez místa", + "dashboard.class.startsAt": "Začátek", + "dashboard.class.booked": "obsazeno", + "dashboard.class.noTemplates": "Zatím žádné šablony lekcí.", + "dashboard.class.noSessions": "Zatím žádné naplánované lekce.", + "dashboard.class.sessionHint": "Vytvořte šablonu a poté z ní naplánujte konkrétní lekce.", + "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", @@ -440,11 +499,21 @@ const dictionaries = { "booking.selectTimeBody": "Vyberte si z dostupných termínů pro služby a lekce.", "booking.empty": "Momentálně nejsou k dispozici žádné termíny.", "booking.emptyHint": "Zkuste to prosím později.", + "booking.emptyDay": "V tento den není nic volného.", + "booking.step.service": "Vyberte službu", + "booking.step.staff": "S kým?", + "booking.step.date": "Vyberte den", + "booking.step.time": "Vyberte čas", + "booking.staff.any": "Bez preference", + "booking.service.free": "Zdarma", + "booking.classes": "Skupinové lekce", "booking.only": "Zbývají", "booking.left": "místa", "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", @@ -804,6 +873,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", @@ -818,6 +890,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", @@ -833,6 +907,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", @@ -907,6 +982,59 @@ 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.zone.businessHours": "Whole business", + "dashboard.classes": "Classes", + "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", + "dashboard.class.durationMin": "Duration (min)", + "dashboard.class.template": "Template", + "dashboard.class.location": "Location", + "dashboard.class.noLocation": "No location", + "dashboard.class.startsAt": "Starts at", + "dashboard.class.booked": "booked", + "dashboard.class.noTemplates": "No class templates yet.", + "dashboard.class.noSessions": "No scheduled sessions yet.", + "dashboard.class.sessionHint": "Create a template, then schedule individual sessions from it.", + "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", @@ -949,11 +1077,21 @@ const dictionaries = { "booking.selectTimeBody": "Choose from available appointment and class times.", "booking.empty": "No bookable times are available right now.", "booking.emptyHint": "Please check again later.", + "booking.emptyDay": "No times available on this day.", + "booking.step.service": "Choose a service", + "booking.step.staff": "With whom?", + "booking.step.date": "Pick a day", + "booking.step.time": "Pick a time", + "booking.staff.any": "No preference", + "booking.service.free": "Free", + "booking.classes": "Group classes", "booking.only": "Only", "booking.left": "left", "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/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/apps/frontend/src/routes/dashboard/bookings-page.tsx b/apps/frontend/src/routes/dashboard/bookings-page.tsx index 946e729..a59133d 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"; @@ -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); @@ -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()); @@ -59,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, }; @@ -89,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") }; @@ -108,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) => ( @@ -129,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") }, ]} /> @@ -196,6 +221,7 @@ function BookingsPage() { @@ -224,7 +250,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..15802b5 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"; @@ -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 { @@ -210,8 +211,18 @@ 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", + new_clients: "dashboard.kpi.newClients", + }; 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/apps/frontend/src/routes/dashboard/layout.tsx b/apps/frontend/src/routes/dashboard/layout.tsx index dd053ee..fcf0ca2 100644 --- a/apps/frontend/src/routes/dashboard/layout.tsx +++ b/apps/frontend/src/routes/dashboard/layout.tsx @@ -8,9 +8,10 @@ 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"; +import { apiClient } from "../../lib/api-client"; export { useDashboardData }; @@ -18,6 +19,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" }, @@ -28,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; @@ -240,6 +268,7 @@ function DashboardShell(props: { children: JSX.Element }) {
@@ -253,6 +282,25 @@ function DashboardShell(props: { children: JSX.Element }) {
+ +
+ +
+
diff --git a/apps/frontend/src/routes/dashboard/overview-page.tsx b/apps/frontend/src/routes/dashboard/overview-page.tsx index 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/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 })), + ]} + /> +
@@ -314,6 +489,116 @@ 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/apps/frontend/src/routes/public-booking-route.tsx b/apps/frontend/src/routes/public-booking-route.tsx index 6a13871..89bbd97 100644 --- a/apps/frontend/src/routes/public-booking-route.tsx +++ b/apps/frontend/src/routes/public-booking-route.tsx @@ -1,10 +1,13 @@ import { A, useParams } from "@solidjs/router"; -import { For, Show, createResource, createSignal } from "solid-js"; +import { For, Show, createMemo, createResource, createSignal } from "solid-js"; import { apiClient } from "../lib/api-client"; import { useI18n } from "../providers/i18n-provider"; import type { components } from "@bookra/api-client/generated/types"; import { BookraCharacter } from "../components/bookra-character"; -import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Input, SkeletonCard, Textarea, Tooltip } from "../components/ui"; +import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Input, SkeletonCard, Textarea } from "../components/ui"; + +type PublicService = components["schemas"]["PublicService"]; +type PublicStaff = components["schemas"]["PublicStaff"]; export function PublicBookingRoute() { const params = useParams(); @@ -18,7 +21,19 @@ export function PublicBookingRoute() { const [customerPhone, setCustomerPhone] = createSignal(""); const [notes, setNotes] = createSignal(""); const [highlightContact, setHighlightContact] = createSignal(false); + const [selectedServiceId, setSelectedServiceId] = createSignal(null); + const [selectedStaffId, setSelectedStaffId] = createSignal(null); + const [selectedDate, setSelectedDate] = createSignal(null); let contactFormRef: HTMLDivElement | undefined; + + const [catalog] = createResource(() => { + const slug = tenantSlug(); + if (!slug) return null; + return apiClient.GET("/v1/public/tenants/{tenantSlug}/catalog", { + params: { path: { tenantSlug: slug } }, + }); + }); + const [availability, { refetch }] = createResource(() => { const slug = tenantSlug(); if (!slug) return null; @@ -31,6 +46,84 @@ export function PublicBookingRoute() { }); }); + const dayParams = createMemo(() => { + const slug = tenantSlug(); + const serviceId = selectedServiceId(); + const date = selectedDate(); + if (!slug || !serviceId || !date) return null; + return { slug, serviceId, date, staffId: selectedStaffId() }; + }); + const [dayAvailability, { refetch: refetchDay }] = createResource(dayParams, (source) => + apiClient.GET("/v1/public/tenants/{tenantSlug}/availability", { + params: { + path: { tenantSlug: source.slug }, + query: { + serviceId: source.serviceId, + date: source.date, + ...(source.staffId ? { staffId: source.staffId } : {}), + }, + }, + }), + ); + + 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); + + const tenantTimezone = createMemo( + () => availability.latest?.data?.timezone ?? dayAvailability.latest?.data?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + + const days = createMemo(() => { + const tz = tenantTimezone(); + const locale = i18n.locale(); + const items: { value: string; weekday: string; day: string; month: string }[] = []; + const now = Date.now(); + for (let offset = 0; offset < 14; offset++) { + const date = new Date(now + offset * 86400000); + items.push({ + value: new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).format(date), + weekday: new Intl.DateTimeFormat(locale, { timeZone: tz, weekday: "short" }).format(date), + day: new Intl.DateTimeFormat(locale, { timeZone: tz, day: "numeric" }).format(date), + month: new Intl.DateTimeFormat(locale, { timeZone: tz, month: "short" }).format(date), + }); + } + return items; + }); + + const formatPrice = (priceCents: number) => { + if (!priceCents) return i18n.t("booking.service.free"); + return new Intl.NumberFormat(i18n.locale() === "cs" ? "cs-CZ" : "en-US", { + style: "currency", + currency: "CZK", + maximumFractionDigits: 0, + }).format(priceCents / 100); + }; + + const pickService = (serviceId: string) => { + setSelectedServiceId(serviceId); + setSelectedDate(null); + setBookingError(null); + }; + + const pickStaff = (staffId: string | null) => { + setSelectedStaffId(staffId); + }; + const bookSlot = async (slot: components["schemas"]["TimeSlot"]) => { if (!customerName().trim() || !customerEmail().trim()) { setBookingError(i18n.t("booking.customerRequired")); @@ -77,6 +170,7 @@ export function PublicBookingRoute() { setSubmittingSlot(null); void refetch(); + void refetchDay(); }; return ( @@ -273,7 +367,7 @@ export function PublicBookingRoute() { {/* Loading state */} - +
@@ -281,96 +375,205 @@ export function PublicBookingRoute() {
- {/* Slots list */} -
- -
- -

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

-

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

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

{slot.label ?? "Bookable slot"}

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

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

-
-
- - + )} + +
+
+ + + {/* Staff preference */} + 1}> +
+

{i18n.t("booking.step.staff")}

+
+ + + {(member) => ( + )} - - + +
- - )} - +
+ + {/* Day picker */} +
+

{i18n.t("booking.step.date")}

+
+ + {(item) => ( + + )} + +
+
+ + {/* Time grid */} + +
+

{i18n.t("booking.step.time")}

+ +
+
+
+
+
+
+ + +
+ +

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

+
+
+
+ + {(slot) => ( + + )} + +
+
+ + +
-
+ + {/* Class sessions */} + 0}> +
+

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

+
+ + {(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")}`} +

+
+
+
+ +
+ )} +
+
+
+ )} +
+
+
+
+ + {/* Empty state */} + +
+ +

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

+

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

+
+
+
diff --git a/apps/frontend/tsconfig.tsbuildinfo b/apps/frontend/tsconfig.tsbuildinfo index aea3823..add008b 100644 --- a/apps/frontend/tsconfig.tsbuildinfo +++ b/apps/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/services-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/staff-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 9bf5dd2..0f6b11b 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -287,6 +287,86 @@ 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; + 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: operations["updateClassTemplate"]; + post?: never; + delete: operations["deleteClassTemplate"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/catalog/customers": { parameters: { query?: never; @@ -351,6 +431,102 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/catalog/services": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listServices"]; + put?: never; + post: operations["createService"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/catalog/services/{serviceID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateService"]; + post?: never; + delete: operations["deleteService"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/catalog/staff": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listStaff"]; + put?: never; + post: operations["createStaff"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/catalog/staff/{staffID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateStaff"]; + post?: never; + delete: operations["deleteStaff"]; + options?: never; + head?: never; + 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; @@ -543,6 +719,22 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/public/tenants/{tenantSlug}/catalog": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPublicCatalog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/public/widget.js": { parameters: { query?: never; @@ -815,6 +1007,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"; @@ -845,6 +1066,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 */ @@ -871,8 +1101,11 @@ export interface components { startsAt: string; }; DashboardKPI: { + change?: string; code: string; label: string; + /** @enum {string} */ + trend?: "up" | "down" | "neutral"; value: string; }; DashboardSummary: { @@ -895,6 +1128,7 @@ export interface components { failedCount: number; processedCount: number; sentCount: number; + skippedCount: number; }; EmailTemplate: { bodyText?: string; @@ -969,6 +1203,11 @@ export interface components { tenantSlug: string; timezone: string; }; + PublicCatalogResponse: { + services: components["schemas"]["PublicService"][]; + staff: components["schemas"]["PublicStaff"][]; + tenantSlug: string; + }; PublicConfig: { /** Format: uri */ apiUrl: string; @@ -976,6 +1215,47 @@ export interface components { environment: string; neonAuthEnabled: boolean; }; + PublicService: { + durationMinutes: number; + /** Format: uuid */ + id: string; + name: string; + priceCents: number; + }; + PublicStaff: { + displayName: string; + /** Format: uuid */ + id: string; + }; + Service: { + /** @description Blocked time after the appointment (0-480) */ + bufferAfterMinutes: number; + /** @description Blocked time before the appointment (0-480) */ + bufferBeforeMinutes: number; + /** Format: date-time */ + createdAt?: string; + /** @description Appointment length in minutes (1-1440) */ + durationMinutes: number; + /** Format: uuid */ + id: string; + name: string; + /** @description Price in the tenant's currency minor units */ + priceCents: number; + /** Format: uuid */ + tenantId: string; + }; + StaffMember: { + active: boolean; + /** Format: date-time */ + createdAt?: string; + displayName: string; + /** Format: uuid */ + id: string; + /** Format: uuid */ + locationId?: string | null; + /** Format: uuid */ + tenantId: string; + }; SubscriptionSnapshot: { cancelAtPeriodEnd: boolean; checkoutUrlAvailable: boolean; @@ -1043,6 +1323,7 @@ export interface components { serviceId?: string | null; /** Format: uuid */ staffId?: string | null; + staffName?: string | null; /** Format: date-time */ startsAt: string; }; @@ -1095,6 +1376,11 @@ export interface components { /** @enum {string} */ type: "closure" | "holiday" | "maintenance"; }; + UpsertClassTemplateRequest: { + capacity: number; + durationMinutes: number; + title: string; + }; UpsertCustomerRequest: { /** Format: email */ email: string; @@ -1109,6 +1395,22 @@ export interface components { /** @enum {string} */ type?: "room" | "private" | "hall"; }; + UpsertServiceRequest: { + bufferAfterMinutes?: number; + bufferBeforeMinutes?: number; + durationMinutes: number; + name: string; + priceCents?: number; + }; + UpsertStaffRequest: { + active?: boolean; + displayName: string; + /** + * Format: uuid + * @description Omit to keep the current assignment; empty string clears it + */ + locationId?: string | null; + }; WidgetSnippet: { code?: string; kind?: string; @@ -1127,6 +1429,8 @@ export interface components { blockedDayID: string; customerID: string; locationID: string; + serviceID: string; + staffID: string; }; requestBodies: never; headers: never; @@ -1746,6 +2050,241 @@ 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; + 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; + }; + }; + }; + 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; + 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?: { @@ -1978,6 +2517,331 @@ export interface operations { }; }; }; + listServices: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Tenant services */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Service"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createService: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertServiceRequest"]; + }; + }; + responses: { + /** @description Service created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Service"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateService: { + parameters: { + query?: never; + header?: never; + path: { + serviceID: components["parameters"]["serviceID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertServiceRequest"]; + }; + }; + responses: { + /** @description Service updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Service"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Service not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteService: { + parameters: { + query?: never; + header?: never; + path: { + serviceID: components["parameters"]["serviceID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Service deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Service not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listStaff: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Tenant staff */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StaffMember"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createStaff: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertStaffRequest"]; + }; + }; + responses: { + /** @description Staff member created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StaffMember"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Plan limit reached */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateStaff: { + parameters: { + query?: never; + header?: never; + path: { + staffID: components["parameters"]["staffID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertStaffRequest"]; + }; + }; + responses: { + /** @description Staff member updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StaffMember"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Staff member not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteStaff: { + parameters: { + query?: never; + header?: never; + path: { + staffID: components["parameters"]["staffID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Staff member deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Staff member not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + 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; @@ -2306,7 +3170,14 @@ export interface operations { }; getPublicAvailability: { parameters: { - query?: never; + query?: { + /** @description Return all slots for a single tenant-local day (YYYY-MM-DD) instead of the capped preview */ + date?: string; + /** @description Restrict appointment slots to a single service */ + serviceId?: string; + /** @description Restrict appointment slots to a single staff member */ + staffId?: string; + }; header?: never; path: { tenantSlug: string; @@ -2326,6 +3197,28 @@ export interface operations { }; }; }; + getPublicCatalog: { + parameters: { + query?: never; + header?: never; + path: { + tenantSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Bookable services and active staff for a tenant booking page */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicCatalogResponse"]; + }; + }; + }; + }; getWidgetScript: { parameters: { query?: never; diff --git a/project.md b/project.md index 26c567c..b7a5e84 100644 --- a/project.md +++ b/project.md @@ -51,8 +51,82 @@ - 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 - - real post-onboarding entity management screens are still missing -- `[next]` Milestone 8: Catalog management, live staging validation, and operational hardening + - 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 + - dashboard summary now resolves `staffName` on bookings and computes `setupCompletion` from real state (brand, location, service, staff, schedule) + - booking modal service field is a select bound to the tenant's real services; blocked-day creation supports optional per-staff scoping + - deleting a service or staff member preserves bookings (FK `SET NULL`) and removes the member's availability rules (`CASCADE`), mirrored in the in-memory repository + - live staging validation, real Neon Auth/Stripe/SMS provider checks, and operational hardening are still open +- `[done/partial]` Milestone 9: Public booking flow with real service/staff selection + - public booking page is now a stepped flow: pick service → optional staff preference → pick day → pick time; class sessions still listed below + - `GET /v1/public/tenants/{slug}/catalog` exposes bookable services and active staff for the booking page + - 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: 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 +- `[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 +- `[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) +- `[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 +- `[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 +- `[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 +- `[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 +- `[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 +- `[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 +- `[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. @@ -279,11 +353,15 @@ Key entities: - goose migration execution is runnable through workspace scripts. - Protected backend requests now perform automatic app-side identity sync before tenant membership resolution. - `/dashboard` now includes first-run onboarding instead of only a passive empty state for unassigned authenticated users. +- Catalog management now covers services and staff end-to-end; staff creation enforces `MaxStaff` entitlements and staff location assignment validates tenant ownership. +- Dashboard summary computes `setupCompletion` from real configured state (brand, location, service, staff, availability rules) rather than a static value. +- Public booking flow offers real service and staff selection via a stepped picker backed by `GET /v1/public/tenants/{slug}/catalog` and filtered availability (`serviceId`, `staffId`, `date`). +- Appointment creation validates that requested times fit inside availability rules, skip blocked days, and reference tenant-owned staff and locations. ## Current Gaps - Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment. - Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants. -- First-time onboarding exists, but there are still no CRUD screens for locations, staff, services, or schedules after workspace creation. +- Post-onboarding CRUD now covers services, staff, locations, blocked days, working hours, and customers; class/session template management and per-staff availability rules still have no management UI. - Stripe lifecycle sync is implemented; still needs live environment validation against real products, price IDs, and webhook signatures. - Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops. - The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup. @@ -293,7 +371,7 @@ Key entities: - sync Neon Auth identities into tenant membership records and onboarding bootstrap - validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure - replace noop notification providers with the first production email transport and a gated SMS transport -- add real app management screens for services, staff, locations, and availability after onboarding +- add management UI for class/session templates and per-staff availability rules so service duration, capacity, and staff schedules are editable after onboarding ## Product Principles - calm, premium, structured UX