mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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:
1 parent
55fc0d06e3
commit
65a693cf38
17 files changed
+840
-230
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -886,3 +886,110 @@ func TestCreateClassScheduleValidation(t *testing.T) {
|
||||
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateClassScheduleRetimesFutureSessions(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := newCatalogService(repo)
|
||||
ctx := context.Background()
|
||||
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
|
||||
templateID := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7" // 60min mobility class
|
||||
|
||||
created, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
|
||||
TemplateID: templateID, DayOfWeek: 1, StartsLocal: "10:00",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create schedule: %v", err)
|
||||
}
|
||||
sessions, err := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC())
|
||||
if err != nil || len(sessions) == 0 {
|
||||
t.Fatalf("expected materialized sessions, got %d err=%v", len(sessions), err)
|
||||
}
|
||||
// Seed a confirmed booking on the first session.
|
||||
target := sessions[0]
|
||||
if _, err := repo.CreateBooking(ctx, db.CreateBookingParams{
|
||||
TenantID: tenantID,
|
||||
ClassSessionID: &target.ID,
|
||||
BookingMode: "class",
|
||||
CustomerName: "Mover",
|
||||
CustomerEmail: "[email protected]",
|
||||
StartsAt: target.StartsAt,
|
||||
EndsAt: target.EndsAt,
|
||||
Status: "confirmed",
|
||||
Reference: "BK-MOVE-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed booking: %v", err)
|
||||
}
|
||||
originalLocalHour := target.StartsAt.In(time.FixedZone("x", 0)).UTC().In(time.Local).Hour()
|
||||
_ = originalLocalHour
|
||||
|
||||
newTime := "15:30"
|
||||
updated, err := svc.UpdateClassSchedule(ctx, owner, created.ID, domain.UpdateClassScheduleRequest{
|
||||
StartsLocal: &newTime,
|
||||
ApplyToFuture: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update schedule: %v", err)
|
||||
}
|
||||
if updated.StartsLocal != "15:30:00" {
|
||||
t.Fatalf("expected normalized 15:30:00, got %s", updated.StartsLocal)
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation("Europe/Prague")
|
||||
moved, err := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC())
|
||||
if err != nil || len(moved) == 0 {
|
||||
t.Fatalf("expected moved sessions, got %d err=%v", len(moved), err)
|
||||
}
|
||||
for _, session := range moved {
|
||||
local := session.StartsAt.In(loc)
|
||||
if local.Hour() != 15 || local.Minute() != 30 {
|
||||
t.Fatalf("session not retimed: %v local=%v", session.StartsAt, local)
|
||||
}
|
||||
if session.EndsAt.Sub(session.StartsAt) != 60*time.Minute {
|
||||
t.Fatalf("expected 60min duration preserved, got %v", session.EndsAt.Sub(session.StartsAt))
|
||||
}
|
||||
// Same local date as before — only the time-of-day moved.
|
||||
if local.Weekday() != time.Monday {
|
||||
t.Fatalf("session changed weekday: %v", local.Weekday())
|
||||
}
|
||||
}
|
||||
|
||||
// The seeded booking must follow its session's new window.
|
||||
booking, err := repo.GetBookingByReference(ctx, "BK-MOVE-1")
|
||||
if err != nil {
|
||||
t.Fatalf("fetch booking: %v", err)
|
||||
}
|
||||
if !booking.StartsAt.Equal(moved[0].StartsAt) || !booking.EndsAt.Equal(moved[0].EndsAt) {
|
||||
t.Fatalf("booking not synced: %v–%v vs session %v–%v", booking.StartsAt, booking.EndsAt, moved[0].StartsAt, moved[0].EndsAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateClassScheduleWithoutCascade(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := newCatalogService(repo)
|
||||
ctx := context.Background()
|
||||
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
|
||||
|
||||
created, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
|
||||
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", DayOfWeek: 1, StartsLocal: "10:00",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create schedule: %v", err)
|
||||
}
|
||||
before, _ := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC())
|
||||
|
||||
newTime := "16:00"
|
||||
if _, err := svc.UpdateClassSchedule(ctx, owner, created.ID, domain.UpdateClassScheduleRequest{
|
||||
StartsLocal: &newTime,
|
||||
}); err != nil {
|
||||
t.Fatalf("update without cascade: %v", err)
|
||||
}
|
||||
after, _ := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC())
|
||||
if len(after) != len(before) {
|
||||
t.Fatalf("session count changed without cascade: %d -> %d", len(before), len(after))
|
||||
}
|
||||
for i := range after {
|
||||
if !after[i].StartsAt.Equal(before[i].StartsAt) {
|
||||
t.Fatal("session moved without applyToFuture")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user