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
@@ -369,19 +369,19 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
|
||||
server.router.POST("/v1/public/bookings", publicRateLimiter.Middleware(), func(c *gin.Context) {
|
||||
var request struct {
|
||||
TenantSlug string `json:"tenantSlug" binding:"required"`
|
||||
BookingMode string `json:"bookingMode" binding:"required"`
|
||||
ServiceID *string `json:"serviceId"`
|
||||
ClassSessionID *string `json:"classSessionId"`
|
||||
StaffID *string `json:"staffId"`
|
||||
LocationID *string `json:"locationId"`
|
||||
CustomerName string `json:"customerName" binding:"required"`
|
||||
CustomerEmail string `json:"customerEmail" binding:"required,email"`
|
||||
CustomerPhone string `json:"customerPhone"`
|
||||
Notes string `json:"notes"`
|
||||
TenantSlug string `json:"tenantSlug" binding:"required"`
|
||||
BookingMode string `json:"bookingMode" binding:"required"`
|
||||
ServiceID *string `json:"serviceId"`
|
||||
ClassSessionID *string `json:"classSessionId"`
|
||||
StaffID *string `json:"staffId"`
|
||||
LocationID *string `json:"locationId"`
|
||||
CustomerName string `json:"customerName" binding:"required"`
|
||||
CustomerEmail string `json:"customerEmail" binding:"required,email"`
|
||||
CustomerPhone string `json:"customerPhone"`
|
||||
Notes string `json:"notes"`
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
StartsAt string `json:"startsAt" binding:"required"`
|
||||
EndsAt string `json:"endsAt" binding:"required"`
|
||||
StartsAt string `json:"startsAt" binding:"required"`
|
||||
EndsAt string `json:"endsAt" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
@@ -1148,6 +1148,26 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
}
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
protected.PATCH("/catalog/class-schedules/:scheduleID", func(c *gin.Context) {
|
||||
var request domain.UpdateClassScheduleRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := catalogService.UpdateClassSchedule(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("scheduleID"), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassSessionNotFound), errors.Is(err, catalog.ErrLocationNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, catalog.ErrInvalidInput):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.DELETE("/catalog/class-schedules/:scheduleID", func(c *gin.Context) {
|
||||
err := catalogService.DeleteClassSchedule(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("scheduleID"))
|
||||
if err != nil {
|
||||
|
||||
@@ -518,13 +518,10 @@ func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference st
|
||||
if err != nil {
|
||||
return domain.MoveBookingResponse{}, err
|
||||
}
|
||||
confirmed, waitlisted := 0, 0
|
||||
confirmed := 0
|
||||
for _, other := range targetBookings {
|
||||
switch other.Status {
|
||||
case "confirmed":
|
||||
if other.Status == "confirmed" {
|
||||
confirmed++
|
||||
case "waitlisted":
|
||||
waitlisted++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,30 +531,15 @@ func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference st
|
||||
newStatus = "waitlisted"
|
||||
}
|
||||
|
||||
// Release the old spot first so the seat/queue position is free before
|
||||
// the retarget lands.
|
||||
if booking.Status == "waitlisted" {
|
||||
_ = s.repo.ResolveWaitlistEntry(ctx, booking.TenantID, currentSession.ID, booking.CustomerEmail)
|
||||
}
|
||||
if err := s.repo.UpdateBookingSession(ctx, booking.ID, target.ID, target.StartsAt, target.EndsAt, newStatus); err != nil {
|
||||
return domain.MoveBookingResponse{}, err
|
||||
}
|
||||
if newStatus == "waitlisted" {
|
||||
if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
|
||||
TenantID: booking.TenantID,
|
||||
ClassSessionID: target.ID,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
Position: waitlisted + 1,
|
||||
}); err != nil {
|
||||
return domain.MoveBookingResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Re-arm reminders for the new window (confirmed only; a queue spot
|
||||
// re-arms on promotion through the shared confirm path).
|
||||
if err := s.repo.DeletePendingReminderJobsForBooking(ctx, booking.ID); err == nil && newStatus == "confirmed" {
|
||||
_ = armReminderJobs(ctx, s.repo, booking.TenantID, booking.ID, target.StartsAt, booking.CustomerPhone)
|
||||
if newStatus == "confirmed" {
|
||||
booking.StartsAt, booking.EndsAt = target.StartsAt, target.EndsAt
|
||||
RearmReminderJobs(ctx, s.repo, booking.TenantID, booking)
|
||||
}
|
||||
|
||||
// A confirmed move frees a seat on the old session — drain its queue.
|
||||
|
||||
@@ -164,17 +164,6 @@ func seedMoveBooking(t *testing.T, repo *db.MemoryRepository, sessionID, referen
|
||||
if err != nil {
|
||||
t.Fatalf("get session: %v", err)
|
||||
}
|
||||
if status == "waitlisted" {
|
||||
if err := repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
|
||||
TenantID: moveTenantID,
|
||||
ClassSessionID: sessionID,
|
||||
CustomerName: "Move Customer",
|
||||
CustomerEmail: "[email protected]",
|
||||
Position: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed waitlist entry: %v", err)
|
||||
}
|
||||
}
|
||||
created, err := repo.CreateBooking(ctx, db.CreateBookingParams{
|
||||
TenantID: moveTenantID,
|
||||
ClassSessionID: &sessionID,
|
||||
|
||||
@@ -50,7 +50,7 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
|
||||
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
|
||||
break
|
||||
}
|
||||
if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil {
|
||||
if err := confirmWaitlistedBooking(ctx, repo, tenantID, booking); err != nil {
|
||||
return promoted, err
|
||||
}
|
||||
confirmed++
|
||||
@@ -104,24 +104,30 @@ func PromoteBooking(ctx context.Context, repo db.Repository, tenantID, bookingID
|
||||
}
|
||||
}
|
||||
|
||||
if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil {
|
||||
if err := confirmWaitlistedBooking(ctx, repo, tenantID, booking); err != nil {
|
||||
return db.BookingRecord{}, err
|
||||
}
|
||||
booking.Status = "confirmed"
|
||||
return booking, nil
|
||||
}
|
||||
|
||||
// confirmWaitlistedBooking flips a waitlisted booking to confirmed, resolves
|
||||
// the ledger entry, and re-arms reminder jobs — a booking promoted after its
|
||||
// reminder already dispatched-as-skipped would otherwise go unreminded.
|
||||
func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID, sessionID string, booking db.BookingRecord) error {
|
||||
// confirmWaitlistedBooking flips a waitlisted booking to confirmed and
|
||||
// re-arms reminder jobs — a booking promoted after its reminder already
|
||||
// dispatched-as-skipped would otherwise go unreminded.
|
||||
func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID string, booking db.BookingRecord) error {
|
||||
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail)
|
||||
RearmReminderJobs(ctx, repo, tenantID, booking)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RearmReminderJobs clears pending reminders for a booking and re-arms them
|
||||
// for its (possibly new) start time. Used after promotion, session moves, and
|
||||
// schedule retimes so reminders always match the booking's live window.
|
||||
func RearmReminderJobs(ctx context.Context, repo db.Repository, tenantID string, booking db.BookingRecord) {
|
||||
_ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID)
|
||||
_ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone)
|
||||
return nil
|
||||
}
|
||||
|
||||
// armReminderJobs schedules the email reminder — and the SMS reminder when the
|
||||
|
||||
@@ -365,19 +365,6 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
|
||||
if status == "waitlisted" && request.ClassSessionID != nil {
|
||||
waitlistPosition := int(countClassBookings(existing, *request.ClassSessionID)) + 1
|
||||
if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
|
||||
TenantID: tenant.ID,
|
||||
ClassSessionID: *request.ClassSessionID,
|
||||
CustomerName: customerName,
|
||||
CustomerEmail: customerEmail,
|
||||
Position: waitlistPosition,
|
||||
}); err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := armReminderJobs(ctx, s.repo, tenant.ID, created.ID, startsAt, request.CustomerPhone); err != nil {
|
||||
return domain.CreateBookingResponse{}, err
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -18,23 +18,23 @@ type DashboardKPI struct {
|
||||
}
|
||||
|
||||
type UpcomingBooking struct {
|
||||
ID string `json:"id"`
|
||||
Reference string `json:"reference"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
StaffName string `json:"staffName,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Reference string `json:"reference"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
StaffName string `json:"staffName,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
// IntakeLabels maps Intake keys to the service's declared field labels.
|
||||
IntakeLabels map[string]string `json:"intakeLabels,omitempty"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Status string `json:"status"`
|
||||
Label string `json:"label,omitempty"`
|
||||
ClassSessionID *string `json:"classSessionId,omitempty"`
|
||||
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Status string `json:"status"`
|
||||
Label string `json:"label,omitempty"`
|
||||
ClassSessionID *string `json:"classSessionId,omitempty"`
|
||||
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
||||
}
|
||||
|
||||
type WidgetSnippet struct {
|
||||
@@ -169,9 +169,9 @@ type CreateBookingRequest struct {
|
||||
Notes string `json:"notes"`
|
||||
// Intake carries structured intake answers keyed by the service's
|
||||
// declared IntakeField keys; only declared keys are stored.
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
StartsAt string `json:"startsAt"`
|
||||
EndsAt string `json:"endsAt"`
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
StartsAt string `json:"startsAt"`
|
||||
EndsAt string `json:"endsAt"`
|
||||
}
|
||||
|
||||
type CreateBookingResponse struct {
|
||||
@@ -334,11 +334,11 @@ type CreateServiceRequest struct {
|
||||
}
|
||||
|
||||
type UpdateServiceRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
DurationMinutes *int `json:"durationMinutes,omitempty"`
|
||||
BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"`
|
||||
BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"`
|
||||
PriceCents *int `json:"priceCents,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
DurationMinutes *int `json:"durationMinutes,omitempty"`
|
||||
BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"`
|
||||
BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"`
|
||||
PriceCents *int `json:"priceCents,omitempty"`
|
||||
IntakeFields *[]IntakeField `json:"intakeFields,omitempty"`
|
||||
}
|
||||
|
||||
@@ -413,16 +413,16 @@ type UpsertClassTemplateRequest struct {
|
||||
}
|
||||
|
||||
type ClassSession struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TemplateID string `json:"templateId"`
|
||||
TemplateTitle string `json:"templateTitle"`
|
||||
LocationID *string `json:"locationId,omitempty"`
|
||||
LocationName string `json:"locationName,omitempty"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Capacity int `json:"capacity"`
|
||||
BookedCount int `json:"bookedCount"`
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TemplateID string `json:"templateId"`
|
||||
TemplateTitle string `json:"templateTitle"`
|
||||
LocationID *string `json:"locationId,omitempty"`
|
||||
LocationName string `json:"locationName,omitempty"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Capacity int `json:"capacity"`
|
||||
BookedCount int `json:"bookedCount"`
|
||||
// WaitlistCount is the number of customers queued for a free seat.
|
||||
WaitlistCount int `json:"waitlistCount"`
|
||||
// ScheduleID links the session back to the recurring rule that generated
|
||||
@@ -492,6 +492,19 @@ type CreateClassScheduleRequest struct {
|
||||
StartsLocal string `json:"startsLocal" binding:"required"` // "HH:MM"
|
||||
}
|
||||
|
||||
// UpdateClassScheduleRequest patches a recurring rule; nil fields keep their
|
||||
// current values. A LocationID of "" clears the location. ApplyToFuture
|
||||
// additionally retimes already-materialized future sessions (and their live
|
||||
// bookings) to the new start time and location; a day-of-week change only
|
||||
// affects sessions materialized afterwards.
|
||||
type UpdateClassScheduleRequest struct {
|
||||
DayOfWeek *int `json:"dayOfWeek"`
|
||||
StartsLocal *string `json:"startsLocal"` // "HH:MM"
|
||||
LocationID *string `json:"locationId"`
|
||||
Active *bool `json:"active"`
|
||||
ApplyToFuture bool `json:"applyToFuture"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CUSTOMER MODELS
|
||||
// ============================================
|
||||
@@ -530,25 +543,25 @@ type UpdateCustomerRequest struct {
|
||||
// ============================================
|
||||
|
||||
type CustomerBookingView struct {
|
||||
Reference string `json:"reference"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
Service string `json:"service"`
|
||||
BusinessName string `json:"businessName"`
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
ServiceID *string `json:"serviceId,omitempty"`
|
||||
StaffID *string `json:"staffId,omitempty"`
|
||||
Reference string `json:"reference"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
Service string `json:"service"`
|
||||
BusinessName string `json:"businessName"`
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
ServiceID *string `json:"serviceId,omitempty"`
|
||||
StaffID *string `json:"staffId,omitempty"`
|
||||
// ClassSessionID identifies session-bound bookings; they cannot be
|
||||
// rescheduled to arbitrary times (see RescheduleBooking).
|
||||
ClassSessionID *string `json:"classSessionId,omitempty"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Location string `json:"location"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
ClassSessionID *string `json:"classSessionId,omitempty"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Location string `json:"location"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
// IntakeLabels maps Intake keys to the service's declared field labels.
|
||||
IntakeLabels map[string]string `json:"intakeLabels,omitempty"`
|
||||
IntakeLabels map[string]string `json:"intakeLabels,omitempty"`
|
||||
// WaitlistPosition is the 1-based queue spot for waitlisted class
|
||||
// bookings; zero for any other status.
|
||||
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
||||
|
||||
Reference in new issue
Block a user