feat(availability): per-staff working hours and class/session management

- Working-hours repo accepts nullable staff scope (pgx + memory); staff
  rules live in availability_rules and drive public slot generation
- Protected routes for staff working hours, class templates, and class
  sessions with tenant-ownership checks and input validation
- Sessions derive endsAt/capacity from templates; list resolves location
  names and live booked counts; template delete cascades sessions,
  session delete preserves bookings via SET NULL (mirrored in memory)
- Zones page: staff-scope selector on working hours, new Classes tab
  with template/session CRUD, CS+EN strings, mobile tab wrapping
- Normalize availability_rules times to HH:MM in the service layer

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 11:01:01 +02:00
1 parent d9aa29dbcb
commit 0405106587
12 files changed
+2069 -58

No files matched your search

+350 -17
View File
@@ -11,21 +11,25 @@ import (
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"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")
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")
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 {
@@ -786,18 +790,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
@@ -823,7 +829,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
@@ -930,3 +1010,256 @@ 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 := strings.TrimSpace(req.Title)
if len(title) < 1 || len(title) > 120 {
return domain.ClassTemplate{}, fmt.Errorf("%w: title must be between 1 and 120 characters", ErrInvalidInput)
}
if req.DurationMinutes < 1 || req.DurationMinutes > 1440 {
return domain.ClassTemplate{}, fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
}
if req.Capacity < 1 || req.Capacity > 500 {
return domain.ClassTemplate{}, fmt.Errorf("%w: capacity must be between 1 and 500", ErrInvalidInput)
}
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
}