mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 23:51:17 +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
@@ -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