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
@@ -127,25 +127,6 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
|
||||
return created, err
|
||||
}
|
||||
|
||||
func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO waitlist_entries (tenant_id, class_session_id, customer_name, customer_email, position)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
`, params.TenantID, params.ClassSessionID, params.CustomerName, params.CustomerEmail, params.Position)
|
||||
return err
|
||||
}
|
||||
|
||||
// ResolveWaitlistEntry marks the pending ledger row for a promoted booking.
|
||||
// Entries carry no booking_id, so the session + customer email pair is the
|
||||
// practical key.
|
||||
func (r *PGRepository) ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE waitlist_entries SET status = 'resolved'
|
||||
WHERE tenant_id = $1 AND class_session_id = $2 AND customer_email = $3 AND status = 'pending'
|
||||
`, tenantID, sessionID, customerEmail)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
|
||||
|
||||
@@ -99,3 +99,89 @@ func (r *PGRepository) MaterializeScheduleSessions(ctx context.Context, tenantID
|
||||
`, tenantID, now, days)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateClassSchedule edits an existing recurring rule; nil fields are kept.
|
||||
// The tenant guard makes the update a no-match error for foreign schedules.
|
||||
func (r *PGRepository) UpdateClassSchedule(ctx context.Context, tenantID string, scheduleID string, params UpdateClassScheduleParams) (ClassScheduleRecord, error) {
|
||||
var record ClassScheduleRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
UPDATE class_schedules SET
|
||||
day_of_week = COALESCE($3, day_of_week),
|
||||
starts_local = COALESCE($4::time, starts_local),
|
||||
location_id = CASE WHEN $5::text IS NULL THEN location_id WHEN $5::text = '' THEN NULL ELSE $5::uuid END,
|
||||
active = COALESCE($6, active)
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
RETURNING `+classScheduleColumns+`
|
||||
`, scheduleID, tenantID, params.DayOfWeek, params.StartsLocal, params.LocationID, params.Active).Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.TemplateID,
|
||||
&record.LocationID,
|
||||
&record.DayOfWeek,
|
||||
&record.StartsLocal,
|
||||
&record.Active,
|
||||
&record.CreatedAt,
|
||||
)
|
||||
return record, err
|
||||
}
|
||||
|
||||
// ListClassSessionsBySchedule returns materialized sessions of one rule,
|
||||
// optionally only those starting after `after`.
|
||||
func (r *PGRepository) ListClassSessionsBySchedule(ctx context.Context, tenantID string, scheduleID string, after time.Time) ([]ClassSessionRecord, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT `+classSessionColumns+`
|
||||
FROM class_sessions cs
|
||||
INNER JOIN class_templates ct ON ct.id = cs.template_id
|
||||
WHERE cs.tenant_id = $1 AND cs.schedule_id = $2 AND cs.starts_at > $3
|
||||
ORDER BY cs.starts_at ASC
|
||||
`, tenantID, scheduleID, after)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []ClassSessionRecord
|
||||
for rows.Next() {
|
||||
var record ClassSessionRecord
|
||||
if err := rows.Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.TemplateID,
|
||||
&record.LocationID,
|
||||
&record.Title,
|
||||
&record.StartsAt,
|
||||
&record.EndsAt,
|
||||
&record.Capacity,
|
||||
&record.ScheduleID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
// RetimeFutureScheduleSessions shifts each future session of a rule to a new
|
||||
// local start time (keeping its calendar date) and optionally a new location.
|
||||
// Live bookings on those sessions are moved to the same window so reminders
|
||||
// and calendars stay in sync. A nil startsLocal keeps session times.
|
||||
func (r *PGRepository) RetimeFutureScheduleSessions(ctx context.Context, tenantID string, scheduleID string, startsLocal *string, locationID *string, now time.Time) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
WITH moved AS (
|
||||
UPDATE class_sessions cs SET
|
||||
starts_at = CASE WHEN $4::time IS NULL THEN cs.starts_at
|
||||
ELSE ((cs.starts_at AT TIME ZONE t.timezone)::date + $4::time) AT TIME ZONE t.timezone END,
|
||||
ends_at = CASE WHEN $4::time IS NULL THEN cs.ends_at
|
||||
ELSE ((cs.starts_at AT TIME ZONE t.timezone)::date + $4::time) AT TIME ZONE t.timezone + make_interval(mins => tpl.duration_minutes) END,
|
||||
location_id = CASE WHEN $5::text IS NULL THEN cs.location_id WHEN $5::text = '' THEN NULL ELSE $5::uuid END
|
||||
FROM tenants t, class_templates tpl
|
||||
WHERE cs.tenant_id = $1 AND cs.schedule_id = $2 AND cs.starts_at > $3
|
||||
AND t.id = cs.tenant_id AND tpl.id = cs.template_id
|
||||
RETURNING cs.id, cs.starts_at, cs.ends_at
|
||||
)
|
||||
UPDATE bookings b SET starts_at = m.starts_at, ends_at = m.ends_at
|
||||
FROM moved m
|
||||
WHERE b.class_session_id = m.id AND b.status IN ('confirmed', 'waitlisted')
|
||||
`, tenantID, scheduleID, now, startsLocal, locationID)
|
||||
return err
|
||||
}
|
||||
@@ -37,13 +37,14 @@ type Repository interface {
|
||||
DeleteClassTemplate(ctx context.Context, templateID string) error
|
||||
ListClassSchedulesByTenant(ctx context.Context, tenantID string) ([]ClassScheduleRecord, error)
|
||||
CreateClassSchedule(ctx context.Context, params CreateClassScheduleParams) (ClassScheduleRecord, error)
|
||||
UpdateClassSchedule(ctx context.Context, tenantID string, scheduleID string, params UpdateClassScheduleParams) (ClassScheduleRecord, error)
|
||||
DeleteClassSchedule(ctx context.Context, tenantID string, scheduleID string) error
|
||||
MaterializeScheduleSessions(ctx context.Context, tenantID string, now time.Time, days int) error
|
||||
ListClassSessionsBySchedule(ctx context.Context, tenantID string, scheduleID string, after time.Time) ([]ClassSessionRecord, error)
|
||||
RetimeFutureScheduleSessions(ctx context.Context, tenantID string, scheduleID string, startsLocal *string, locationID *string, now time.Time) error
|
||||
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
|
||||
ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error)
|
||||
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
|
||||
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
|
||||
ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error
|
||||
CreateReminderJob(ctx context.Context, params ReminderJobParams) error
|
||||
ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error)
|
||||
MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error
|
||||
@@ -278,8 +279,8 @@ type ServiceRecord struct {
|
||||
PriceCents int
|
||||
// IntakeFields is the raw JSON list of declared structured intake fields;
|
||||
// the catalog service parses and validates it into domain.IntakeField.
|
||||
IntakeFields string
|
||||
CreatedAt time.Time
|
||||
IntakeFields string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateServiceParams struct {
|
||||
@@ -343,14 +344,14 @@ type ActivityEventRecord struct {
|
||||
}
|
||||
|
||||
type ActivityEventParams struct {
|
||||
TenantID string
|
||||
ActorKind string
|
||||
TenantID string
|
||||
ActorKind string
|
||||
ActorSubject string
|
||||
ActorName string
|
||||
Action string
|
||||
EntityType string
|
||||
EntityID *string
|
||||
Summary string
|
||||
ActorName string
|
||||
Action string
|
||||
EntityType string
|
||||
EntityID *string
|
||||
Summary string
|
||||
}
|
||||
|
||||
type CreateStaffParams struct {
|
||||
@@ -418,14 +419,14 @@ type CreateClassSessionParams struct {
|
||||
// ClassScheduleRecord is a weekly recurring rule that materializes concrete
|
||||
// class_sessions rows. DayOfWeek follows Go's time.Weekday (Sunday=0).
|
||||
type ClassScheduleRecord struct {
|
||||
ID string
|
||||
TenantID string
|
||||
TemplateID string
|
||||
LocationID *string
|
||||
DayOfWeek int
|
||||
ID string
|
||||
TenantID string
|
||||
TemplateID string
|
||||
LocationID *string
|
||||
DayOfWeek int
|
||||
StartsLocal string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateClassScheduleParams struct {
|
||||
@@ -436,6 +437,15 @@ type CreateClassScheduleParams struct {
|
||||
StartsLocal string
|
||||
}
|
||||
|
||||
// UpdateClassScheduleParams carries optional rule fields; nil means keep.
|
||||
// LocationID nil keeps the location, a pointer to "" clears it.
|
||||
type UpdateClassScheduleParams struct {
|
||||
DayOfWeek *int
|
||||
StartsLocal *string
|
||||
LocationID *string
|
||||
Active *bool
|
||||
}
|
||||
|
||||
type BookingRecord struct {
|
||||
ID string
|
||||
TenantID string
|
||||
@@ -453,8 +463,8 @@ type BookingRecord struct {
|
||||
ManageToken string
|
||||
Notes string
|
||||
// Intake is the raw JSON object of structured intake answers.
|
||||
Intake string
|
||||
CreatedAt time.Time
|
||||
Intake string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateBookingParams struct {
|
||||
@@ -483,14 +493,6 @@ type CreatedBooking struct {
|
||||
ManageToken string
|
||||
}
|
||||
|
||||
type WaitlistEntryParams struct {
|
||||
TenantID string
|
||||
ClassSessionID string
|
||||
CustomerName string
|
||||
CustomerEmail string
|
||||
Position int
|
||||
}
|
||||
|
||||
type DashboardMetrics struct {
|
||||
BookingsCount int
|
||||
CancellationsCount int
|
||||
@@ -878,32 +880,31 @@ func (r *PGRepository) UpdateBookingSession(ctx context.Context, bookingID strin
|
||||
// ============================================
|
||||
|
||||
type MemoryRepository struct {
|
||||
tenant TenantRecord
|
||||
membership TenantMembershipRecord
|
||||
brand BrandProfileRecord
|
||||
services []ServiceRecord
|
||||
rules []AvailabilityRuleRecord
|
||||
classTemplates []ClassTemplateRecord
|
||||
classSessions []ClassSessionRecord
|
||||
classSchedules []ClassScheduleRecord
|
||||
bookings []BookingRecord
|
||||
waitlistSize int
|
||||
reminderJobs []ReminderJobRecord
|
||||
deliveryLogs []NotificationDeliveryLogParams
|
||||
billingSnapshot BillingSnapshotRecord
|
||||
recordedEvents map[string]struct{}
|
||||
locations []LocationRecord
|
||||
staff []StaffRecord
|
||||
blockedDays []BlockedDayRecord
|
||||
customers []CustomerRecord
|
||||
workingHours []WorkingHoursRecord
|
||||
smsSettings TenantSMSSettingsRecord
|
||||
smsLogs []SMSUsageLogRecord
|
||||
smsReports []SMSMonthlyReportRecord
|
||||
emailTemplates []EmailTemplateRecord
|
||||
tenantFeedToken string
|
||||
staffFeedTokens map[string]string
|
||||
activityEvents []ActivityEventRecord
|
||||
tenant TenantRecord
|
||||
membership TenantMembershipRecord
|
||||
brand BrandProfileRecord
|
||||
services []ServiceRecord
|
||||
rules []AvailabilityRuleRecord
|
||||
classTemplates []ClassTemplateRecord
|
||||
classSessions []ClassSessionRecord
|
||||
classSchedules []ClassScheduleRecord
|
||||
bookings []BookingRecord
|
||||
reminderJobs []ReminderJobRecord
|
||||
deliveryLogs []NotificationDeliveryLogParams
|
||||
billingSnapshot BillingSnapshotRecord
|
||||
recordedEvents map[string]struct{}
|
||||
locations []LocationRecord
|
||||
staff []StaffRecord
|
||||
blockedDays []BlockedDayRecord
|
||||
customers []CustomerRecord
|
||||
workingHours []WorkingHoursRecord
|
||||
smsSettings TenantSMSSettingsRecord
|
||||
smsLogs []SMSUsageLogRecord
|
||||
smsReports []SMSMonthlyReportRecord
|
||||
emailTemplates []EmailTemplateRecord
|
||||
tenantFeedToken string
|
||||
staffFeedTokens map[string]string
|
||||
activityEvents []ActivityEventRecord
|
||||
notificationCursors map[string]time.Time
|
||||
}
|
||||
|
||||
@@ -1582,6 +1583,109 @@ func (r *MemoryRepository) DeleteClassSchedule(_ context.Context, tenantID strin
|
||||
return fmt.Errorf("class schedule not found")
|
||||
}
|
||||
|
||||
// UpdateClassSchedule mirrors the PG implementation: nil fields are kept,
|
||||
// a LocationID pointer to "" clears the location.
|
||||
func (r *MemoryRepository) UpdateClassSchedule(_ context.Context, tenantID string, scheduleID string, params UpdateClassScheduleParams) (ClassScheduleRecord, error) {
|
||||
for i, schedule := range r.classSchedules {
|
||||
if schedule.ID != scheduleID || schedule.TenantID != tenantID {
|
||||
continue
|
||||
}
|
||||
if params.DayOfWeek != nil {
|
||||
r.classSchedules[i].DayOfWeek = *params.DayOfWeek
|
||||
}
|
||||
if params.StartsLocal != nil {
|
||||
r.classSchedules[i].StartsLocal = normalizeLocalTime(*params.StartsLocal)
|
||||
}
|
||||
if params.LocationID != nil {
|
||||
if *params.LocationID == "" {
|
||||
r.classSchedules[i].LocationID = nil
|
||||
} else {
|
||||
loc := *params.LocationID
|
||||
r.classSchedules[i].LocationID = &loc
|
||||
}
|
||||
}
|
||||
if params.Active != nil {
|
||||
r.classSchedules[i].Active = *params.Active
|
||||
}
|
||||
return r.classSchedules[i], nil
|
||||
}
|
||||
return ClassScheduleRecord{}, fmt.Errorf("class schedule not found")
|
||||
}
|
||||
|
||||
// ListClassSessionsBySchedule mirrors the PG implementation.
|
||||
func (r *MemoryRepository) ListClassSessionsBySchedule(_ context.Context, tenantID string, scheduleID string, after time.Time) ([]ClassSessionRecord, error) {
|
||||
if tenantID != r.tenant.ID {
|
||||
return nil, nil
|
||||
}
|
||||
var out []ClassSessionRecord
|
||||
for _, session := range r.classSessions {
|
||||
if session.ScheduleID != nil && *session.ScheduleID == scheduleID && session.StartsAt.After(after) {
|
||||
out = append(out, session)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].StartsAt.Before(out[j].StartsAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RetimeFutureScheduleSessions mirrors the PG implementation: future sessions
|
||||
// of the rule keep their local date but move to the new start time and
|
||||
// template duration, and live bookings on them follow.
|
||||
func (r *MemoryRepository) RetimeFutureScheduleSessions(_ context.Context, tenantID string, scheduleID string, startsLocal *string, locationID *string, now time.Time) error {
|
||||
if tenantID != r.tenant.ID {
|
||||
return nil
|
||||
}
|
||||
loc, err := time.LoadLocation(r.tenant.Timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
var hour, minute int
|
||||
if startsLocal != nil {
|
||||
parts := strings.Split(normalizeLocalTime(*startsLocal), ":")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
hour, _ = strconv.Atoi(parts[0])
|
||||
minute, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
for i := range r.classSessions {
|
||||
session := &r.classSessions[i]
|
||||
if session.ScheduleID == nil || *session.ScheduleID != scheduleID || !session.StartsAt.After(now) {
|
||||
continue
|
||||
}
|
||||
if startsLocal != nil {
|
||||
day := session.StartsAt.In(loc)
|
||||
newStart := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc)
|
||||
var duration time.Duration
|
||||
for _, tpl := range r.classTemplates {
|
||||
if tpl.ID == session.TemplateID {
|
||||
duration = time.Duration(tpl.DurationMinutes) * time.Minute
|
||||
break
|
||||
}
|
||||
}
|
||||
session.StartsAt = newStart
|
||||
session.EndsAt = newStart.Add(duration)
|
||||
}
|
||||
if locationID != nil && *locationID != "" {
|
||||
loc := *locationID
|
||||
session.LocationID = &loc
|
||||
}
|
||||
}
|
||||
for i := range r.bookings {
|
||||
booking := &r.bookings[i]
|
||||
if booking.ClassSessionID == nil || (booking.Status != "confirmed" && booking.Status != "waitlisted") {
|
||||
continue
|
||||
}
|
||||
for _, session := range r.classSessions {
|
||||
if session.ID == *booking.ClassSessionID && session.ScheduleID != nil && *session.ScheduleID == scheduleID {
|
||||
booking.StartsAt = session.StartsAt
|
||||
booking.EndsAt = session.EndsAt
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaterializeScheduleSessions mirrors the PG implementation: for each active
|
||||
// rule, concrete sessions are added for matching weekdays within the next
|
||||
// `days` days. Sessions already materialized for a rule+start are skipped.
|
||||
@@ -1802,18 +1906,6 @@ func (r *MemoryRepository) seedDemoBookings() {
|
||||
r.bookings[0].Intake = `{"health_notes":"Sensitive skin","first_visit":"yes"}`
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error {
|
||||
r.waitlistSize++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ResolveWaitlistEntry(_ context.Context, _, _, _ string) error {
|
||||
if r.waitlistSize > 0 {
|
||||
r.waitlistSize--
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error {
|
||||
reference := params.BookingID
|
||||
startsAt := time.Now().UTC()
|
||||
@@ -2489,8 +2581,8 @@ func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string
|
||||
}
|
||||
}
|
||||
r.workingHours = append(r.workingHours, WorkingHoursRecord{
|
||||
TenantID: tenantID,
|
||||
DayOfWeek: dayOfWeek,
|
||||
TenantID: tenantID,
|
||||
DayOfWeek: dayOfWeek,
|
||||
StartsLocal: *params.StartsLocal,
|
||||
EndsLocal: *params.EndsLocal,
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user