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

+225 -13
View File
@@ -26,6 +26,13 @@ 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)
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
@@ -100,8 +107,8 @@ type Repository interface {
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)
@@ -293,6 +300,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
@@ -304,6 +327,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
@@ -719,6 +751,7 @@ type MemoryRepository struct {
brand BrandProfileRecord
services []ServiceRecord
rules []AvailabilityRuleRecord
classTemplates []ClassTemplateRecord
classSessions []ClassSessionRecord
bookings []BookingRecord
waitlistSize int
@@ -810,6 +843,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,
@@ -1179,6 +1215,97 @@ 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) 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 {
@@ -1699,29 +1826,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