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

+115
View File
@@ -0,0 +1,115 @@
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) 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
}
+33 -15
View File
@@ -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
}
+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