mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
Merge pull request #17 from Dvorinka/feat/m32-series-schedule-edits
feat: series-aware schedule edits + waitlist ledger removal (M32)
This commit is contained in:
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"`
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
-- waitlist_entries was written on waitlist join/promotion but never read:
|
||||
-- every displayed position derives from bookings.created_at ordering via
|
||||
-- GetWaitlistPosition. Dropping the dead ledger rather than keeping a
|
||||
-- write-only table that implies state it does not hold.
|
||||
DROP TABLE IF EXISTS waitlist_entries;
|
||||
|
||||
-- +goose Down
|
||||
CREATE TABLE IF NOT EXISTS waitlist_entries (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
class_session_id uuid NOT NULL REFERENCES class_sessions(id) ON DELETE CASCADE,
|
||||
customer_name text NOT NULL,
|
||||
customer_email text NOT NULL,
|
||||
position integer NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -1395,6 +1395,35 @@ paths:
|
||||
"404":
|
||||
description: Template or location not found
|
||||
/v1/catalog/class-schedules/{scheduleID}:
|
||||
patch:
|
||||
tags: [Catalog]
|
||||
operationId: updateClassSchedule
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: scheduleID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateClassScheduleRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated class schedule
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ClassSchedule"
|
||||
"400":
|
||||
description: Invalid request
|
||||
"404":
|
||||
description: Class schedule not found
|
||||
delete:
|
||||
tags: [Catalog]
|
||||
operationId: deleteClassSchedule
|
||||
@@ -3054,6 +3083,30 @@ components:
|
||||
startsLocal:
|
||||
type: string
|
||||
description: Tenant-local start time, HH:MM
|
||||
UpdateClassScheduleRequest:
|
||||
type: object
|
||||
description: >-
|
||||
Patches a recurring rule; absent fields are kept. locationId set to the
|
||||
empty string clears the location. When applyToFuture is true and time or
|
||||
location changed, already-materialized future sessions move to the new
|
||||
start time (keeping their local date) and location, and their live
|
||||
bookings follow. A dayOfWeek change only steers sessions materialized
|
||||
afterwards.
|
||||
properties:
|
||||
dayOfWeek:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 6
|
||||
startsLocal:
|
||||
type: string
|
||||
description: Tenant-local start time, HH:MM
|
||||
locationId:
|
||||
type: string
|
||||
description: Location UUID, or empty string to clear
|
||||
active:
|
||||
type: boolean
|
||||
applyToFuture:
|
||||
type: boolean
|
||||
PlanEntitlements:
|
||||
type: object
|
||||
required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking]
|
||||
|
||||
@@ -452,6 +452,11 @@ const dictionaries = {
|
||||
"dashboard.class.startsLocal": "Čas",
|
||||
"dashboard.class.addSchedule": "Přidat pravidlo",
|
||||
"dashboard.class.noSchedules": "Zatím žádná opakující se pravidla.",
|
||||
"dashboard.class.editSchedule": "Upravit pravidlo",
|
||||
"dashboard.class.applyToFuture": "Použít na budoucí lekce",
|
||||
"dashboard.class.applyToFutureHint": "Přesune již vytvořené lekce na nový čas a místo, rezervace se přesunou s nimi.",
|
||||
"dashboard.class.ruleActive": "Pravidlo je aktivní",
|
||||
"dashboard.class.inactive": "neaktivní",
|
||||
"dashboard.class.dow.0": "Neděle",
|
||||
"dashboard.class.dow.1": "Pondělí",
|
||||
"dashboard.class.dow.2": "Úterý",
|
||||
@@ -1083,6 +1088,11 @@ const dictionaries = {
|
||||
"dashboard.class.startsLocal": "Time",
|
||||
"dashboard.class.addSchedule": "Add rule",
|
||||
"dashboard.class.noSchedules": "No recurring rules yet.",
|
||||
"dashboard.class.editSchedule": "Edit rule",
|
||||
"dashboard.class.applyToFuture": "Apply to future sessions",
|
||||
"dashboard.class.applyToFutureHint": "Moves already-created sessions to the new time and location; bookings move with them.",
|
||||
"dashboard.class.ruleActive": "Rule is active",
|
||||
"dashboard.class.inactive": "inactive",
|
||||
"dashboard.class.dow.0": "Sunday",
|
||||
"dashboard.class.dow.1": "Monday",
|
||||
"dashboard.class.dow.2": "Tuesday",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { apiClient } from "../../lib/api-client";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { getInitials } from "../../components/dashboard/types";
|
||||
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon } from "../../components/dashboard/icons";
|
||||
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon, CheckIcon, XIcon } from "../../components/dashboard/icons";
|
||||
import { DashboardLayout, useDashboardData } from "./layout";
|
||||
|
||||
function ZonesPage() {
|
||||
@@ -35,6 +35,13 @@ function ZonesPage() {
|
||||
const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal("");
|
||||
const [newSchedLocationId, setNewSchedLocationId] = createSignal("");
|
||||
const [schedSaving, setSchedSaving] = createSignal(false);
|
||||
const [editingSchedId, setEditingSchedId] = createSignal<string | null>(null);
|
||||
const [editSchedDayOfWeek, setEditSchedDayOfWeek] = createSignal("1");
|
||||
const [editSchedStartsLocal, setEditSchedStartsLocal] = createSignal("");
|
||||
const [editSchedLocationId, setEditSchedLocationId] = createSignal("");
|
||||
const [editSchedApplyFuture, setEditSchedApplyFuture] = createSignal(true);
|
||||
const [editSchedActive, setEditSchedActive] = createSignal(true);
|
||||
const [schedEditSaving, setSchedEditSaving] = createSignal(false);
|
||||
const [expandedSessionId, setExpandedSessionId] = createSignal<string | null>(null);
|
||||
const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
|
||||
// Booking IDs whose promote attempt hit a full session — awaiting the
|
||||
@@ -142,8 +149,8 @@ function ZonesPage() {
|
||||
if (!bearer || tab !== "classes") return [];
|
||||
if (bearer.startsWith("demo.")) {
|
||||
return [
|
||||
{ id: "sch1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 3, startsLocal: "10:00:00", active: true },
|
||||
{ id: "sch2", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30:00", active: true },
|
||||
{ id: "sch1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationId: "z1", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 3, startsLocal: "10:00:00", active: true },
|
||||
{ id: "sch2", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationId: "z1", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30:00", active: true },
|
||||
];
|
||||
}
|
||||
const response = await (apiClient as any).GET("/v1/catalog/class-schedules", authHeaders());
|
||||
@@ -405,6 +412,42 @@ function ZonesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const startEditSchedule = (sch: any) => {
|
||||
setEditingSchedId(sch.id);
|
||||
setEditSchedDayOfWeek(String(sch.dayOfWeek));
|
||||
setEditSchedStartsLocal(String(sch.startsLocal).slice(0, 5));
|
||||
setEditSchedLocationId(sch.locationId ?? "");
|
||||
setEditSchedApplyFuture(true);
|
||||
setEditSchedActive(sch.active !== false);
|
||||
};
|
||||
|
||||
const handleSaveSchedule = async () => {
|
||||
const bearer = data.token();
|
||||
const id = editingSchedId();
|
||||
if (!id) return;
|
||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
|
||||
setSchedEditSaving(true);
|
||||
try {
|
||||
await (apiClient as any).PATCH(`/v1/catalog/class-schedules/${id}`, {
|
||||
...authHeaders(),
|
||||
body: {
|
||||
dayOfWeek: parseInt(editSchedDayOfWeek(), 10),
|
||||
startsLocal: editSchedStartsLocal(),
|
||||
locationId: editSchedLocationId(),
|
||||
active: editSchedActive(),
|
||||
applyToFuture: editSchedApplyFuture(),
|
||||
},
|
||||
});
|
||||
setEditingSchedId(null);
|
||||
void refetchSchedules();
|
||||
void refetchSessions();
|
||||
} catch {
|
||||
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
|
||||
} finally {
|
||||
setSchedEditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSchedule = async (id: string) => {
|
||||
const bearer = data.token();
|
||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
|
||||
@@ -713,19 +756,68 @@ function ZonesPage() {
|
||||
<div class="divide-y divide-border/60">
|
||||
<For each={classSchedules() ?? []}>
|
||||
{(sch: any) => (
|
||||
<div class="flex items-center justify-between p-4 px-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
|
||||
<div>
|
||||
<p class="font-medium text-ink">{sch.templateTitle}</p>
|
||||
<p class="text-sm text-ink-muted">
|
||||
{`${weekdayName(sch.dayOfWeek)} ${String(sch.startsLocal).slice(0, 5)}`}
|
||||
{sch.locationName ? ` • ${sch.locationName}` : ""}
|
||||
</p>
|
||||
<Show
|
||||
when={editingSchedId() === sch.id}
|
||||
fallback={
|
||||
<div class="flex items-center justify-between p-4 px-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
|
||||
<div>
|
||||
<p class="font-medium text-ink">{sch.templateTitle}</p>
|
||||
<p class="text-sm text-ink-muted">
|
||||
{`${weekdayName(sch.dayOfWeek)} ${String(sch.startsLocal).slice(0, 5)}`}
|
||||
{sch.locationName ? ` • ${sch.locationName}` : ""}
|
||||
{!sch.active ? ` • ${data.i18n.t("dashboard.class.inactive")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button onClick={() => startEditSchedule(sch)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle/40 rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.class.editSchedule")}><PencilIcon /></button>
|
||||
<button onClick={() => void handleDeleteSchedule(sch.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="p-4 px-6 bg-canvas-subtle/40">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Select
|
||||
label={data.i18n.t("dashboard.class.weekday")}
|
||||
value={editSchedDayOfWeek()}
|
||||
onChange={(v) => setEditSchedDayOfWeek(v)}
|
||||
options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({ value: String(d), label: weekdayName(d) }))}
|
||||
/>
|
||||
<Input type="time" label={data.i18n.t("dashboard.class.startsLocal")} value={editSchedStartsLocal()} onInput={(e) => setEditSchedStartsLocal(e.currentTarget.value)} />
|
||||
<Select
|
||||
label={data.i18n.t("dashboard.class.location")}
|
||||
value={editSchedLocationId()}
|
||||
onChange={(v) => setEditSchedLocationId(v)}
|
||||
options={[
|
||||
{ value: "", label: data.i18n.t("dashboard.class.noLocation") },
|
||||
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-start gap-2 mt-4 cursor-pointer">
|
||||
<input type="checkbox" checked={editSchedActive()} onChange={(e) => setEditSchedActive(e.currentTarget.checked)} class="mt-0.5 accent-accent" />
|
||||
<span class="text-sm font-medium text-ink">{data.i18n.t("dashboard.class.ruleActive")}</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-2 mt-3 cursor-pointer">
|
||||
<input type="checkbox" checked={editSchedApplyFuture()} onChange={(e) => setEditSchedApplyFuture(e.currentTarget.checked)} class="mt-0.5 accent-accent" />
|
||||
<span>
|
||||
<span class="text-sm font-medium text-ink">{data.i18n.t("dashboard.class.applyToFuture")}</span>
|
||||
<span class="block text-xs text-ink-muted">{data.i18n.t("dashboard.class.applyToFutureHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<button onClick={() => void handleSaveSchedule()} disabled={schedEditSaving() || !editSchedStartsLocal()} class="btn-primary text-sm disabled:opacity-60">
|
||||
<CheckIcon /> {schedEditSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.saveChanges")}
|
||||
</button>
|
||||
<button onClick={() => setEditingSchedId(null)} class="btn-secondary text-sm">
|
||||
<XIcon /> {data.i18n.t("dashboard.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => void handleDeleteSchedule(sch.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
@@ -429,7 +429,7 @@ export interface paths {
|
||||
delete: operations["deleteClassSchedule"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
patch: operations["updateClassSchedule"];
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/catalog/class-sessions": {
|
||||
@@ -1912,6 +1912,16 @@ export interface components {
|
||||
primaryColor?: string;
|
||||
siteUrl?: string;
|
||||
};
|
||||
/** @description Patches a recurring rule; absent fields are kept. locationId set to the empty string clears the location. When applyToFuture is true and time or location changed, already-materialized future sessions move to the new start time (keeping their local date) and location, and their live bookings follow. A dayOfWeek change only steers sessions materialized afterwards. */
|
||||
UpdateClassScheduleRequest: {
|
||||
active?: boolean;
|
||||
applyToFuture?: boolean;
|
||||
dayOfWeek?: number;
|
||||
/** @description Location UUID, or empty string to clear */
|
||||
locationId?: string;
|
||||
/** @description Tenant-local start time, HH:MM */
|
||||
startsLocal?: string;
|
||||
};
|
||||
UpdateEmailTemplateRequest: {
|
||||
body?: string;
|
||||
isEnabled?: boolean;
|
||||
@@ -2904,6 +2914,46 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
updateClassSchedule: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
scheduleID: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateClassScheduleRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Updated class schedule */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ClassSchedule"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Class schedule not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
listClassSessions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
+10
-2
@@ -228,6 +228,14 @@
|
||||
- `service-report.csv` gains a `completed` column
|
||||
- verified live: `/v1/metrics/services` returns correct aggregates for seeded trailing-window bookings; `intakeLabels` present in dashboard summary; modal renders declared labels in CS; focused tests cover blocked-day capacity (tenant + staff scope) and service aggregation
|
||||
- remaining: live staging validation on Neon + pgx repository path
|
||||
- `[done]` Milestone 32: Series-aware schedule edits + waitlist ledger removal
|
||||
- `PATCH /v1/catalog/class-schedules/:id` patches a recurring rule (`dayOfWeek`, `startsLocal`, `locationId`, `active`); absent fields are kept, `locationId: ""` clears
|
||||
- `applyToFuture` retimes already-materialized future sessions to the new local start time on their existing calendar dates (tenant-timezone aware), applies the new location, moves confirmed/waitlisted bookings to the new window, and re-arms reminders via exported `bookings.RearmReminderJobs`
|
||||
- a `dayOfWeek` change steers only sessions materialized afterwards — existing dates stay put (documented semantics)
|
||||
- zones-page schedule rows gain an inline editor (weekday/time/location + active toggle + apply-to-future checkbox) in CS and EN
|
||||
- `waitlist_entries` dropped (migration 00020): the ledger was written on join/promotion but never read — positions derive from bookings' `created_at`
|
||||
- verified live: PATCH retime cascades to session and booking windows; without `applyToFuture` sessions stay; 400/404 paths correct; focused tests cover cascade and no-cascade paths
|
||||
- remaining: live staging validation on Neon + pgx repository path
|
||||
|
||||
## Overview
|
||||
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
|
||||
@@ -478,11 +486,11 @@ Key entities:
|
||||
Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle:
|
||||
|
||||
- **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request.
|
||||
- **Series-aware session edits**: M29's rules generate sessions, but editing a rule means delete-and-recreate; an "apply to future sessions of this rule" update (time shift, capacity change) would remove the workaround.
|
||||
- **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach.
|
||||
- **Scheduled report delivery**: period-end exports emailed automatically (cron + existing mail transport) instead of manual downloads — pairs naturally with M28's report endpoints.
|
||||
- **Intake reporting**: M31 shows labels, but answers aren't queryable — filtering or aggregating yes-no answers (e.g. "first visits this month") needs a stored projection, not JSONB scans; defer until a reporting request exists.
|
||||
- **Waitlist ledger cleanup**: `waitlist_entries` is vestigial — every displayed position derives from booking `created_at`, so the table is written but never read; drop it or wire it as the source of truth, don't leave both half-alive.
|
||||
- **Bulk session shift**: M32 retimes future sessions of one rule; a genuine weekday *move* (relocate existing sessions to the new weekday, not just steer future materialization) and multi-rule shifts (holiday weeks) are still manual — defer until requested.
|
||||
- **Per-session capacity override**: session capacity still comes only from the template; letting staff override capacity on one session (or one rule) is a small schema addition if a real need appears.
|
||||
|
||||
## Product Principles
|
||||
- calm, premium, structured UX
|
||||
|
||||
Reference in new issue
Block a user