mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +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) {
|
server.router.POST("/v1/public/bookings", publicRateLimiter.Middleware(), func(c *gin.Context) {
|
||||||
var request struct {
|
var request struct {
|
||||||
TenantSlug string `json:"tenantSlug" binding:"required"`
|
TenantSlug string `json:"tenantSlug" binding:"required"`
|
||||||
BookingMode string `json:"bookingMode" binding:"required"`
|
BookingMode string `json:"bookingMode" binding:"required"`
|
||||||
ServiceID *string `json:"serviceId"`
|
ServiceID *string `json:"serviceId"`
|
||||||
ClassSessionID *string `json:"classSessionId"`
|
ClassSessionID *string `json:"classSessionId"`
|
||||||
StaffID *string `json:"staffId"`
|
StaffID *string `json:"staffId"`
|
||||||
LocationID *string `json:"locationId"`
|
LocationID *string `json:"locationId"`
|
||||||
CustomerName string `json:"customerName" binding:"required"`
|
CustomerName string `json:"customerName" binding:"required"`
|
||||||
CustomerEmail string `json:"customerEmail" binding:"required,email"`
|
CustomerEmail string `json:"customerEmail" binding:"required,email"`
|
||||||
CustomerPhone string `json:"customerPhone"`
|
CustomerPhone string `json:"customerPhone"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
Intake map[string]string `json:"intake,omitempty"`
|
Intake map[string]string `json:"intake,omitempty"`
|
||||||
StartsAt string `json:"startsAt" binding:"required"`
|
StartsAt string `json:"startsAt" binding:"required"`
|
||||||
EndsAt string `json:"endsAt" binding:"required"`
|
EndsAt string `json:"endsAt" binding:"required"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
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)
|
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) {
|
protected.DELETE("/catalog/class-schedules/:scheduleID", func(c *gin.Context) {
|
||||||
err := catalogService.DeleteClassSchedule(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("scheduleID"))
|
err := catalogService.DeleteClassSchedule(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("scheduleID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -518,13 +518,10 @@ func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.MoveBookingResponse{}, err
|
return domain.MoveBookingResponse{}, err
|
||||||
}
|
}
|
||||||
confirmed, waitlisted := 0, 0
|
confirmed := 0
|
||||||
for _, other := range targetBookings {
|
for _, other := range targetBookings {
|
||||||
switch other.Status {
|
if other.Status == "confirmed" {
|
||||||
case "confirmed":
|
|
||||||
confirmed++
|
confirmed++
|
||||||
case "waitlisted":
|
|
||||||
waitlisted++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,30 +531,15 @@ func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference st
|
|||||||
newStatus = "waitlisted"
|
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 {
|
if err := s.repo.UpdateBookingSession(ctx, booking.ID, target.ID, target.StartsAt, target.EndsAt, newStatus); err != nil {
|
||||||
return domain.MoveBookingResponse{}, err
|
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-arm reminders for the new window (confirmed only; a queue spot
|
||||||
// re-arms on promotion through the shared confirm path).
|
// re-arms on promotion through the shared confirm path).
|
||||||
if err := s.repo.DeletePendingReminderJobsForBooking(ctx, booking.ID); err == nil && newStatus == "confirmed" {
|
if newStatus == "confirmed" {
|
||||||
_ = armReminderJobs(ctx, s.repo, booking.TenantID, booking.ID, target.StartsAt, booking.CustomerPhone)
|
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.
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("get session: %v", err)
|
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{
|
created, err := repo.CreateBooking(ctx, db.CreateBookingParams{
|
||||||
TenantID: moveTenantID,
|
TenantID: moveTenantID,
|
||||||
ClassSessionID: &sessionID,
|
ClassSessionID: &sessionID,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
|
|||||||
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
|
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil {
|
if err := confirmWaitlistedBooking(ctx, repo, tenantID, booking); err != nil {
|
||||||
return promoted, err
|
return promoted, err
|
||||||
}
|
}
|
||||||
confirmed++
|
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
|
return db.BookingRecord{}, err
|
||||||
}
|
}
|
||||||
booking.Status = "confirmed"
|
booking.Status = "confirmed"
|
||||||
return booking, nil
|
return booking, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// confirmWaitlistedBooking flips a waitlisted booking to confirmed, resolves
|
// confirmWaitlistedBooking flips a waitlisted booking to confirmed and
|
||||||
// the ledger entry, and re-arms reminder jobs — a booking promoted after its
|
// re-arms reminder jobs — a booking promoted after its reminder already
|
||||||
// reminder already dispatched-as-skipped would otherwise go unreminded.
|
// dispatched-as-skipped would otherwise go unreminded.
|
||||||
func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID, sessionID string, booking db.BookingRecord) error {
|
func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID string, booking db.BookingRecord) error {
|
||||||
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
|
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
|
||||||
return err
|
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)
|
_ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID)
|
||||||
_ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone)
|
_ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// armReminderJobs schedules the email reminder — and the SMS reminder when the
|
// 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
|
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 {
|
if err := armReminderJobs(ctx, s.repo, tenant.ID, created.ID, startsAt, request.CustomerPhone); err != nil {
|
||||||
return domain.CreateBookingResponse{}, err
|
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 {
|
if req.DayOfWeek < 0 || req.DayOfWeek > 6 {
|
||||||
return domain.ClassSchedule{}, fmt.Errorf("%w: dayOfWeek must be 0-6", ErrInvalidInput)
|
return domain.ClassSchedule{}, fmt.Errorf("%w: dayOfWeek must be 0-6", ErrInvalidInput)
|
||||||
}
|
}
|
||||||
startsLocal := strings.TrimSpace(req.StartsLocal)
|
startsLocal, err := normalizeScheduleLocalTime(req.StartsLocal)
|
||||||
parts := strings.SplitN(startsLocal, ":", 2)
|
if err != nil {
|
||||||
if len(parts) != 2 {
|
return domain.ClassSchedule{}, err
|
||||||
return domain.ClassSchedule{}, 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 domain.ClassSchedule{}, fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput)
|
|
||||||
}
|
|
||||||
startsLocal = fmt.Sprintf("%02d:%02d:00", hour, minute)
|
|
||||||
|
|
||||||
if req.LocationID != nil {
|
if req.LocationID != nil {
|
||||||
location, err := s.repo.GetLocationByID(ctx, *req.LocationID)
|
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
|
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
|
// DeleteClassSchedule removes a recurring rule. Sessions already materialized
|
||||||
// stay in place; no new ones are written.
|
// stay in place; no new ones are written.
|
||||||
func (s *Service) DeleteClassSchedule(ctx context.Context, principal domain.Principal, scheduleID string) error {
|
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)
|
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
|
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 {
|
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
|
||||||
_, err := r.pool.Exec(ctx, `
|
_, err := r.pool.Exec(ctx, `
|
||||||
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
|
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)
|
`, tenantID, now, days)
|
||||||
return err
|
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
|
DeleteClassTemplate(ctx context.Context, templateID string) error
|
||||||
ListClassSchedulesByTenant(ctx context.Context, tenantID string) ([]ClassScheduleRecord, error)
|
ListClassSchedulesByTenant(ctx context.Context, tenantID string) ([]ClassScheduleRecord, error)
|
||||||
CreateClassSchedule(ctx context.Context, params CreateClassScheduleParams) (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
|
DeleteClassSchedule(ctx context.Context, tenantID string, scheduleID string) error
|
||||||
MaterializeScheduleSessions(ctx context.Context, tenantID string, now time.Time, days int) 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)
|
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
|
||||||
ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error)
|
ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error)
|
||||||
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, 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
|
CreateReminderJob(ctx context.Context, params ReminderJobParams) error
|
||||||
ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error)
|
ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error)
|
||||||
MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error
|
MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error
|
||||||
@@ -278,8 +279,8 @@ type ServiceRecord struct {
|
|||||||
PriceCents int
|
PriceCents int
|
||||||
// IntakeFields is the raw JSON list of declared structured intake fields;
|
// IntakeFields is the raw JSON list of declared structured intake fields;
|
||||||
// the catalog service parses and validates it into domain.IntakeField.
|
// the catalog service parses and validates it into domain.IntakeField.
|
||||||
IntakeFields string
|
IntakeFields string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateServiceParams struct {
|
type CreateServiceParams struct {
|
||||||
@@ -343,14 +344,14 @@ type ActivityEventRecord struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ActivityEventParams struct {
|
type ActivityEventParams struct {
|
||||||
TenantID string
|
TenantID string
|
||||||
ActorKind string
|
ActorKind string
|
||||||
ActorSubject string
|
ActorSubject string
|
||||||
ActorName string
|
ActorName string
|
||||||
Action string
|
Action string
|
||||||
EntityType string
|
EntityType string
|
||||||
EntityID *string
|
EntityID *string
|
||||||
Summary string
|
Summary string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateStaffParams struct {
|
type CreateStaffParams struct {
|
||||||
@@ -418,14 +419,14 @@ type CreateClassSessionParams struct {
|
|||||||
// ClassScheduleRecord is a weekly recurring rule that materializes concrete
|
// ClassScheduleRecord is a weekly recurring rule that materializes concrete
|
||||||
// class_sessions rows. DayOfWeek follows Go's time.Weekday (Sunday=0).
|
// class_sessions rows. DayOfWeek follows Go's time.Weekday (Sunday=0).
|
||||||
type ClassScheduleRecord struct {
|
type ClassScheduleRecord struct {
|
||||||
ID string
|
ID string
|
||||||
TenantID string
|
TenantID string
|
||||||
TemplateID string
|
TemplateID string
|
||||||
LocationID *string
|
LocationID *string
|
||||||
DayOfWeek int
|
DayOfWeek int
|
||||||
StartsLocal string
|
StartsLocal string
|
||||||
Active bool
|
Active bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateClassScheduleParams struct {
|
type CreateClassScheduleParams struct {
|
||||||
@@ -436,6 +437,15 @@ type CreateClassScheduleParams struct {
|
|||||||
StartsLocal string
|
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 {
|
type BookingRecord struct {
|
||||||
ID string
|
ID string
|
||||||
TenantID string
|
TenantID string
|
||||||
@@ -453,8 +463,8 @@ type BookingRecord struct {
|
|||||||
ManageToken string
|
ManageToken string
|
||||||
Notes string
|
Notes string
|
||||||
// Intake is the raw JSON object of structured intake answers.
|
// Intake is the raw JSON object of structured intake answers.
|
||||||
Intake string
|
Intake string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateBookingParams struct {
|
type CreateBookingParams struct {
|
||||||
@@ -483,14 +493,6 @@ type CreatedBooking struct {
|
|||||||
ManageToken string
|
ManageToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
type WaitlistEntryParams struct {
|
|
||||||
TenantID string
|
|
||||||
ClassSessionID string
|
|
||||||
CustomerName string
|
|
||||||
CustomerEmail string
|
|
||||||
Position int
|
|
||||||
}
|
|
||||||
|
|
||||||
type DashboardMetrics struct {
|
type DashboardMetrics struct {
|
||||||
BookingsCount int
|
BookingsCount int
|
||||||
CancellationsCount int
|
CancellationsCount int
|
||||||
@@ -878,32 +880,31 @@ func (r *PGRepository) UpdateBookingSession(ctx context.Context, bookingID strin
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
type MemoryRepository struct {
|
type MemoryRepository struct {
|
||||||
tenant TenantRecord
|
tenant TenantRecord
|
||||||
membership TenantMembershipRecord
|
membership TenantMembershipRecord
|
||||||
brand BrandProfileRecord
|
brand BrandProfileRecord
|
||||||
services []ServiceRecord
|
services []ServiceRecord
|
||||||
rules []AvailabilityRuleRecord
|
rules []AvailabilityRuleRecord
|
||||||
classTemplates []ClassTemplateRecord
|
classTemplates []ClassTemplateRecord
|
||||||
classSessions []ClassSessionRecord
|
classSessions []ClassSessionRecord
|
||||||
classSchedules []ClassScheduleRecord
|
classSchedules []ClassScheduleRecord
|
||||||
bookings []BookingRecord
|
bookings []BookingRecord
|
||||||
waitlistSize int
|
reminderJobs []ReminderJobRecord
|
||||||
reminderJobs []ReminderJobRecord
|
deliveryLogs []NotificationDeliveryLogParams
|
||||||
deliveryLogs []NotificationDeliveryLogParams
|
billingSnapshot BillingSnapshotRecord
|
||||||
billingSnapshot BillingSnapshotRecord
|
recordedEvents map[string]struct{}
|
||||||
recordedEvents map[string]struct{}
|
locations []LocationRecord
|
||||||
locations []LocationRecord
|
staff []StaffRecord
|
||||||
staff []StaffRecord
|
blockedDays []BlockedDayRecord
|
||||||
blockedDays []BlockedDayRecord
|
customers []CustomerRecord
|
||||||
customers []CustomerRecord
|
workingHours []WorkingHoursRecord
|
||||||
workingHours []WorkingHoursRecord
|
smsSettings TenantSMSSettingsRecord
|
||||||
smsSettings TenantSMSSettingsRecord
|
smsLogs []SMSUsageLogRecord
|
||||||
smsLogs []SMSUsageLogRecord
|
smsReports []SMSMonthlyReportRecord
|
||||||
smsReports []SMSMonthlyReportRecord
|
emailTemplates []EmailTemplateRecord
|
||||||
emailTemplates []EmailTemplateRecord
|
tenantFeedToken string
|
||||||
tenantFeedToken string
|
staffFeedTokens map[string]string
|
||||||
staffFeedTokens map[string]string
|
activityEvents []ActivityEventRecord
|
||||||
activityEvents []ActivityEventRecord
|
|
||||||
notificationCursors map[string]time.Time
|
notificationCursors map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1582,6 +1583,109 @@ func (r *MemoryRepository) DeleteClassSchedule(_ context.Context, tenantID strin
|
|||||||
return fmt.Errorf("class schedule not found")
|
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
|
// MaterializeScheduleSessions mirrors the PG implementation: for each active
|
||||||
// rule, concrete sessions are added for matching weekdays within the next
|
// rule, concrete sessions are added for matching weekdays within the next
|
||||||
// `days` days. Sessions already materialized for a rule+start are skipped.
|
// `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"}`
|
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 {
|
func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error {
|
||||||
reference := params.BookingID
|
reference := params.BookingID
|
||||||
startsAt := time.Now().UTC()
|
startsAt := time.Now().UTC()
|
||||||
@@ -2489,8 +2581,8 @@ func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
r.workingHours = append(r.workingHours, WorkingHoursRecord{
|
r.workingHours = append(r.workingHours, WorkingHoursRecord{
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
DayOfWeek: dayOfWeek,
|
DayOfWeek: dayOfWeek,
|
||||||
StartsLocal: *params.StartsLocal,
|
StartsLocal: *params.StartsLocal,
|
||||||
EndsLocal: *params.EndsLocal,
|
EndsLocal: *params.EndsLocal,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,23 +18,23 @@ type DashboardKPI struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpcomingBooking struct {
|
type UpcomingBooking struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Reference string `json:"reference"`
|
Reference string `json:"reference"`
|
||||||
CustomerName string `json:"customerName"`
|
CustomerName string `json:"customerName"`
|
||||||
CustomerEmail string `json:"customerEmail"`
|
CustomerEmail string `json:"customerEmail"`
|
||||||
Service string `json:"service,omitempty"`
|
Service string `json:"service,omitempty"`
|
||||||
Location string `json:"location,omitempty"`
|
Location string `json:"location,omitempty"`
|
||||||
StaffName string `json:"staffName,omitempty"`
|
StaffName string `json:"staffName,omitempty"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
Intake map[string]string `json:"intake,omitempty"`
|
Intake map[string]string `json:"intake,omitempty"`
|
||||||
// IntakeLabels maps Intake keys to the service's declared field labels.
|
// 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"`
|
||||||
StartsAt time.Time `json:"startsAt"`
|
StartsAt time.Time `json:"startsAt"`
|
||||||
EndsAt time.Time `json:"endsAt"`
|
EndsAt time.Time `json:"endsAt"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
ClassSessionID *string `json:"classSessionId,omitempty"`
|
ClassSessionID *string `json:"classSessionId,omitempty"`
|
||||||
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WidgetSnippet struct {
|
type WidgetSnippet struct {
|
||||||
@@ -169,9 +169,9 @@ type CreateBookingRequest struct {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
// Intake carries structured intake answers keyed by the service's
|
// Intake carries structured intake answers keyed by the service's
|
||||||
// declared IntakeField keys; only declared keys are stored.
|
// declared IntakeField keys; only declared keys are stored.
|
||||||
Intake map[string]string `json:"intake,omitempty"`
|
Intake map[string]string `json:"intake,omitempty"`
|
||||||
StartsAt string `json:"startsAt"`
|
StartsAt string `json:"startsAt"`
|
||||||
EndsAt string `json:"endsAt"`
|
EndsAt string `json:"endsAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateBookingResponse struct {
|
type CreateBookingResponse struct {
|
||||||
@@ -334,11 +334,11 @@ type CreateServiceRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateServiceRequest struct {
|
type UpdateServiceRequest struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
DurationMinutes *int `json:"durationMinutes,omitempty"`
|
DurationMinutes *int `json:"durationMinutes,omitempty"`
|
||||||
BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"`
|
BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"`
|
||||||
BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"`
|
BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"`
|
||||||
PriceCents *int `json:"priceCents,omitempty"`
|
PriceCents *int `json:"priceCents,omitempty"`
|
||||||
IntakeFields *[]IntakeField `json:"intakeFields,omitempty"`
|
IntakeFields *[]IntakeField `json:"intakeFields,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,16 +413,16 @@ type UpsertClassTemplateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ClassSession struct {
|
type ClassSession struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TenantID string `json:"tenantId"`
|
TenantID string `json:"tenantId"`
|
||||||
TemplateID string `json:"templateId"`
|
TemplateID string `json:"templateId"`
|
||||||
TemplateTitle string `json:"templateTitle"`
|
TemplateTitle string `json:"templateTitle"`
|
||||||
LocationID *string `json:"locationId,omitempty"`
|
LocationID *string `json:"locationId,omitempty"`
|
||||||
LocationName string `json:"locationName,omitempty"`
|
LocationName string `json:"locationName,omitempty"`
|
||||||
StartsAt time.Time `json:"startsAt"`
|
StartsAt time.Time `json:"startsAt"`
|
||||||
EndsAt time.Time `json:"endsAt"`
|
EndsAt time.Time `json:"endsAt"`
|
||||||
Capacity int `json:"capacity"`
|
Capacity int `json:"capacity"`
|
||||||
BookedCount int `json:"bookedCount"`
|
BookedCount int `json:"bookedCount"`
|
||||||
// WaitlistCount is the number of customers queued for a free seat.
|
// WaitlistCount is the number of customers queued for a free seat.
|
||||||
WaitlistCount int `json:"waitlistCount"`
|
WaitlistCount int `json:"waitlistCount"`
|
||||||
// ScheduleID links the session back to the recurring rule that generated
|
// 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"
|
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
|
// CUSTOMER MODELS
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -530,25 +543,25 @@ type UpdateCustomerRequest struct {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
type CustomerBookingView struct {
|
type CustomerBookingView struct {
|
||||||
Reference string `json:"reference"`
|
Reference string `json:"reference"`
|
||||||
CustomerName string `json:"customerName"`
|
CustomerName string `json:"customerName"`
|
||||||
CustomerEmail string `json:"customerEmail"`
|
CustomerEmail string `json:"customerEmail"`
|
||||||
Service string `json:"service"`
|
Service string `json:"service"`
|
||||||
BusinessName string `json:"businessName"`
|
BusinessName string `json:"businessName"`
|
||||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||||
ServiceID *string `json:"serviceId,omitempty"`
|
ServiceID *string `json:"serviceId,omitempty"`
|
||||||
StaffID *string `json:"staffId,omitempty"`
|
StaffID *string `json:"staffId,omitempty"`
|
||||||
// ClassSessionID identifies session-bound bookings; they cannot be
|
// ClassSessionID identifies session-bound bookings; they cannot be
|
||||||
// rescheduled to arbitrary times (see RescheduleBooking).
|
// rescheduled to arbitrary times (see RescheduleBooking).
|
||||||
ClassSessionID *string `json:"classSessionId,omitempty"`
|
ClassSessionID *string `json:"classSessionId,omitempty"`
|
||||||
StartsAt time.Time `json:"startsAt"`
|
StartsAt time.Time `json:"startsAt"`
|
||||||
EndsAt time.Time `json:"endsAt"`
|
EndsAt time.Time `json:"endsAt"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
Intake map[string]string `json:"intake,omitempty"`
|
Intake map[string]string `json:"intake,omitempty"`
|
||||||
// IntakeLabels maps Intake keys to the service's declared field labels.
|
// 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
|
// WaitlistPosition is the 1-based queue spot for waitlisted class
|
||||||
// bookings; zero for any other status.
|
// bookings; zero for any other status.
|
||||||
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
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":
|
"404":
|
||||||
description: Template or location not found
|
description: Template or location not found
|
||||||
/v1/catalog/class-schedules/{scheduleID}:
|
/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:
|
delete:
|
||||||
tags: [Catalog]
|
tags: [Catalog]
|
||||||
operationId: deleteClassSchedule
|
operationId: deleteClassSchedule
|
||||||
@@ -3054,6 +3083,30 @@ components:
|
|||||||
startsLocal:
|
startsLocal:
|
||||||
type: string
|
type: string
|
||||||
description: Tenant-local start time, HH:MM
|
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:
|
PlanEntitlements:
|
||||||
type: object
|
type: object
|
||||||
required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking]
|
required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking]
|
||||||
|
|||||||
@@ -452,6 +452,11 @@ const dictionaries = {
|
|||||||
"dashboard.class.startsLocal": "Čas",
|
"dashboard.class.startsLocal": "Čas",
|
||||||
"dashboard.class.addSchedule": "Přidat pravidlo",
|
"dashboard.class.addSchedule": "Přidat pravidlo",
|
||||||
"dashboard.class.noSchedules": "Zatím žádná opakující se pravidla.",
|
"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.0": "Neděle",
|
||||||
"dashboard.class.dow.1": "Pondělí",
|
"dashboard.class.dow.1": "Pondělí",
|
||||||
"dashboard.class.dow.2": "Úterý",
|
"dashboard.class.dow.2": "Úterý",
|
||||||
@@ -1083,6 +1088,11 @@ const dictionaries = {
|
|||||||
"dashboard.class.startsLocal": "Time",
|
"dashboard.class.startsLocal": "Time",
|
||||||
"dashboard.class.addSchedule": "Add rule",
|
"dashboard.class.addSchedule": "Add rule",
|
||||||
"dashboard.class.noSchedules": "No recurring rules yet.",
|
"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.0": "Sunday",
|
||||||
"dashboard.class.dow.1": "Monday",
|
"dashboard.class.dow.1": "Monday",
|
||||||
"dashboard.class.dow.2": "Tuesday",
|
"dashboard.class.dow.2": "Tuesday",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { apiClient } from "../../lib/api-client";
|
|||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Select } from "../../components/ui/select";
|
import { Select } from "../../components/ui/select";
|
||||||
import { getInitials } from "../../components/dashboard/types";
|
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";
|
import { DashboardLayout, useDashboardData } from "./layout";
|
||||||
|
|
||||||
function ZonesPage() {
|
function ZonesPage() {
|
||||||
@@ -35,6 +35,13 @@ function ZonesPage() {
|
|||||||
const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal("");
|
const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal("");
|
||||||
const [newSchedLocationId, setNewSchedLocationId] = createSignal("");
|
const [newSchedLocationId, setNewSchedLocationId] = createSignal("");
|
||||||
const [schedSaving, setSchedSaving] = createSignal(false);
|
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 [expandedSessionId, setExpandedSessionId] = createSignal<string | null>(null);
|
||||||
const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
|
const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
|
||||||
// Booking IDs whose promote attempt hit a full session — awaiting the
|
// Booking IDs whose promote attempt hit a full session — awaiting the
|
||||||
@@ -142,8 +149,8 @@ function ZonesPage() {
|
|||||||
if (!bearer || tab !== "classes") return [];
|
if (!bearer || tab !== "classes") return [];
|
||||||
if (bearer.startsWith("demo.")) {
|
if (bearer.startsWith("demo.")) {
|
||||||
return [
|
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: "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", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30: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());
|
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 handleDeleteSchedule = async (id: string) => {
|
||||||
const bearer = data.token();
|
const bearer = data.token();
|
||||||
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
|
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">
|
<div class="divide-y divide-border/60">
|
||||||
<For each={classSchedules() ?? []}>
|
<For each={classSchedules() ?? []}>
|
||||||
{(sch: any) => (
|
{(sch: any) => (
|
||||||
<div class="flex items-center justify-between p-4 px-6">
|
<Show
|
||||||
<div class="flex items-center gap-4">
|
when={editingSchedId() === sch.id}
|
||||||
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
|
fallback={
|
||||||
<div>
|
<div class="flex items-center justify-between p-4 px-6">
|
||||||
<p class="font-medium text-ink">{sch.templateTitle}</p>
|
<div class="flex items-center gap-4">
|
||||||
<p class="text-sm text-ink-muted">
|
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
|
||||||
{`${weekdayName(sch.dayOfWeek)} ${String(sch.startsLocal).slice(0, 5)}`}
|
<div>
|
||||||
{sch.locationName ? ` • ${sch.locationName}` : ""}
|
<p class="font-medium text-ink">{sch.templateTitle}</p>
|
||||||
</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>
|
||||||
</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>
|
</Show>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ export interface paths {
|
|||||||
delete: operations["deleteClassSchedule"];
|
delete: operations["deleteClassSchedule"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
patch: operations["updateClassSchedule"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/v1/catalog/class-sessions": {
|
"/v1/catalog/class-sessions": {
|
||||||
@@ -1912,6 +1912,16 @@ export interface components {
|
|||||||
primaryColor?: string;
|
primaryColor?: string;
|
||||||
siteUrl?: 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: {
|
UpdateEmailTemplateRequest: {
|
||||||
body?: string;
|
body?: string;
|
||||||
isEnabled?: boolean;
|
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: {
|
listClassSessions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+10
-2
@@ -228,6 +228,14 @@
|
|||||||
- `service-report.csv` gains a `completed` column
|
- `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
|
- 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
|
- 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
|
## 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.
|
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:
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Product Principles
|
||||||
- calm, premium, structured UX
|
- calm, premium, structured UX
|
||||||
|
|||||||
Reference in new issue
Block a user