feat: series-aware schedule edits + waitlist ledger removal (M32)

PATCH /v1/catalog/class-schedules/:id patches a recurring rule and,
with applyToFuture, retimes materialized future sessions to the new
local start time on their existing dates — moving confirmed and
waitlisted bookings with them and re-arming reminders. Zones-page
schedule rows gain an inline editor (CS/EN).

Also drops the vestigial waitlist_entries table (migration 00020):
positions derive from bookings.created_at, so the ledger was written
but never read.

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-22 20:59:34 +02:00
1 parent 55fc0d06e3
commit 65a693cf38
17 files changed
+840 -230

No files matched your search

+126 -10
View File
@@ -1387,17 +1387,10 @@ func (s *Service) CreateClassSchedule(ctx context.Context, principal domain.Prin
if req.DayOfWeek < 0 || req.DayOfWeek > 6 {
return domain.ClassSchedule{}, fmt.Errorf("%w: dayOfWeek must be 0-6", ErrInvalidInput)
}
startsLocal := strings.TrimSpace(req.StartsLocal)
parts := strings.SplitN(startsLocal, ":", 2)
if len(parts) != 2 {
return domain.ClassSchedule{}, fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput)
startsLocal, err := normalizeScheduleLocalTime(req.StartsLocal)
if err != nil {
return domain.ClassSchedule{}, err
}
hour, errH := strconv.Atoi(parts[0])
minute, errM := strconv.Atoi(parts[1])
if errH != nil || errM != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return domain.ClassSchedule{}, fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput)
}
startsLocal = fmt.Sprintf("%02d:%02d:00", hour, minute)
if req.LocationID != nil {
location, err := s.repo.GetLocationByID(ctx, *req.LocationID)
@@ -1445,6 +1438,129 @@ func (s *Service) CreateClassSchedule(ctx context.Context, principal domain.Prin
return out, nil
}
// UpdateClassSchedule patches a recurring rule. When ApplyToFuture is set and
// the time or location changed, already-materialized future sessions move to
// the new start time (same local date) and location, their live bookings
// follow, and confirmed bookings get reminders re-armed. A weekday change only
// steers sessions materialized afterwards — existing dates are kept.
func (s *Service) UpdateClassSchedule(ctx context.Context, principal domain.Principal, scheduleID string, req domain.UpdateClassScheduleRequest) (domain.ClassSchedule, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.ClassSchedule{}, ErrTenantMembership
}
tenantID := membership.Tenant.ID
schedules, err := s.repo.ListClassSchedulesByTenant(ctx, tenantID)
if err != nil {
return domain.ClassSchedule{}, err
}
var current *db.ClassScheduleRecord
for i := range schedules {
if schedules[i].ID == scheduleID {
current = &schedules[i]
break
}
}
if current == nil {
return domain.ClassSchedule{}, ErrClassSessionNotFound
}
params := db.UpdateClassScheduleParams{
DayOfWeek: req.DayOfWeek,
LocationID: req.LocationID,
Active: req.Active,
}
if req.DayOfWeek != nil && (*req.DayOfWeek < 0 || *req.DayOfWeek > 6) {
return domain.ClassSchedule{}, fmt.Errorf("%w: dayOfWeek must be 0-6", ErrInvalidInput)
}
var startsLocal *string
if req.StartsLocal != nil {
normalized, err := normalizeScheduleLocalTime(*req.StartsLocal)
if err != nil {
return domain.ClassSchedule{}, err
}
startsLocal = &normalized
params.StartsLocal = startsLocal
}
if req.LocationID != nil && *req.LocationID != "" {
location, err := s.repo.GetLocationByID(ctx, *req.LocationID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ClassSchedule{}, ErrLocationNotFound
}
return domain.ClassSchedule{}, err
}
if location.TenantID != tenantID {
return domain.ClassSchedule{}, ErrLocationNotFound
}
}
rec, err := s.repo.UpdateClassSchedule(ctx, tenantID, scheduleID, params)
if err != nil {
if strings.Contains(err.Error(), "not found") {
return domain.ClassSchedule{}, ErrClassSessionNotFound
}
return domain.ClassSchedule{}, err
}
if req.ApplyToFuture && (startsLocal != nil || req.LocationID != nil) {
now := time.Now().UTC()
if err := s.repo.RetimeFutureScheduleSessions(ctx, tenantID, scheduleID, startsLocal, req.LocationID, now); err != nil {
return domain.ClassSchedule{}, err
}
// Re-arm reminders for confirmed bookings on the moved sessions.
sessions, err := s.repo.ListClassSessionsBySchedule(ctx, tenantID, scheduleID, now)
if err != nil {
return domain.ClassSchedule{}, err
}
for _, session := range sessions {
sessionBookings, err := s.repo.ListBookingsBySession(ctx, session.ID)
if err != nil {
continue
}
for _, booking := range sessionBookings {
if booking.Status == "confirmed" {
bookings.RearmReminderJobs(ctx, s.repo, tenantID, booking)
}
}
}
}
template, _ := s.repo.GetClassTemplateByID(ctx, rec.TemplateID)
out := domain.ClassSchedule{
ID: rec.ID,
TenantID: rec.TenantID,
TemplateID: rec.TemplateID,
TemplateTitle: template.Title,
LocationID: rec.LocationID,
DayOfWeek: rec.DayOfWeek,
StartsLocal: rec.StartsLocal,
Active: rec.Active,
}
if rec.LocationID != nil {
if location, err := s.repo.GetLocationByID(ctx, *rec.LocationID); err == nil {
out.LocationName = location.Name
}
}
return out, nil
}
// normalizeScheduleLocalTime validates "HH:MM" and returns the canonical
// "HH:MM:SS" form the schedules table stores.
func normalizeScheduleLocalTime(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) != 2 {
return "", fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput)
}
hour, errH := strconv.Atoi(parts[0])
minute, errM := strconv.Atoi(parts[1])
if errH != nil || errM != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return "", fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput)
}
return fmt.Sprintf("%02d:%02d:00", hour, minute), nil
}
// DeleteClassSchedule removes a recurring rule. Sessions already materialized
// stay in place; no new ones are written.
func (s *Service) DeleteClassSchedule(ctx context.Context, principal domain.Principal, scheduleID string) error {