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

+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
}