package bookings import ( "context" "crussell/clock" "crussell/db" "crussell/handlers/scheduling" "crussell/mw" "crypto/sha256" "encoding/json" "errors" "fmt" "log" "log/slog" "net" "net/http" "time" "github.com/jackc/pgx/v5" ) // ServiceOverrideRequest represents override values for a specific service in a reservation type ServiceOverrideRequest struct { ServiceID string `json:"service_id"` OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"` } // AdminReserveSlotRequest represents the request payload for admin slot reservation type AdminReserveSlotRequest struct { UserID *string `json:"user_id"` StartTime time.Time `json:"start_time"` ServiceIDs []string `json:"service_ids"` CustomServiceIDs []string `json:"custom_service_ids,omitempty"` ServiceOverrides []ServiceOverrideRequest `json:"service_overrides"` TTLMinutes int `json:"ttl_minutes"` // 15 for both walk-in and call-in ReservationType string `json:"reservation_type"` // "walkin" or "callin" DurationMinutes int `json:"duration_minutes"` // explicit duration for walk-in (ignored for call-in) OutOfHours bool `json:"out_of_hours"` } // AdminReserveSlotResponse represents the response for admin slot reservation type AdminReserveSlotResponse struct { ID string `json:"id"` StartTime time.Time `json:"start_time"` DurationMinutes int `json:"duration_minutes"` ExpiresAt time.Time `json:"expires_at"` TTLMinutes int `json:"ttl_minutes"` } // AdminReserveSlotHandler creates a temporary admin slot reservation (walk-in or call-in) func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) { adminID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || adminID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req AdminReserveSlotRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.StartTime.IsZero() { http.Error(w, "start_time is required", http.StatusBadRequest) return } if req.ReservationType != "walkin" && req.ReservationType != "callin" { http.Error(w, "reservation_type must be 'walkin' or 'callin'", http.StatusBadRequest) return } if req.TTLMinutes == 0 { req.TTLMinutes = 15 } var svcDuration int if req.ReservationType == "callin" { if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 { http.Error(w, "At least one service or custom service is required for call-in bookings", http.StatusBadRequest) return } if req.StartTime.Before(clock.Now()) { http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) return } var err error allIDs := append([]string{}, req.ServiceIDs...) allIDs = append(allIDs, req.CustomServiceIDs...) svcDuration, err = calculateServiceDurationWithOverrides(r.Context(), allIDs, req.ServiceOverrides) if err != nil { log.Printf("Failed to calculate duration: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if svcDuration == 0 { http.Error(w, "Invalid service IDs", http.StatusBadRequest) return } } else { if req.DurationMinutes <= 0 { http.Error(w, "duration_minutes is required for walk-in reservations", http.StatusBadRequest) return } svcDuration = req.DurationMinutes allowablePast := clock.Now().Add(-1 * time.Minute) if req.StartTime.Before(allowablePast) { http.Error(w, "Start time cannot be more than 1 minute in the past", http.StatusBadRequest) return } } endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute) if !req.OutOfHours { localStart := req.StartTime.In(londonLocation) // DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert. weekday := int((localStart.Weekday() + 6) % 7) closeStr, err := getClosingTimeForDate(r.Context(), db.Conn, weekday, localStart) if err != nil { log.Printf("Failed to get hours: %v", err) http.Error(w, "Could not verify hours", http.StatusInternalServerError) return } // 00:00 means the day is closed under the staged schedule — reject outright if closeStr == "00:00" || closeStr == "00:00:00" { http.Error(w, "Not open on this day", http.StatusBadRequest) return } localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation) if err := checkClosingHours(localEndLondon, closeStr); err != nil { if errors.Is(err, ErrPastClosing) { http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest) } else { http.Error(w, "Invalid closing time in schedule", http.StatusInternalServerError) } return } } // Clean up existing admin reservation BEFORE the overlap check, // using db.Conn.Exec so the delete is visible to the separate connection // used by CheckTimeBlockerOverlap. The in-transaction DELETE is kept // as a safety net for the insert-phase. // Also clean up anonymous reservations matching this admin's IP // (edge case: admin previously reserved without authentication). ip := r.Header.Get("CF-Connecting-IP") if ip == "" { ip, _, _ = net.SplitHostPort(r.RemoteAddr) if ip == "" { ip = r.RemoteAddr } } ipHash := fmt.Sprintf("%x", sha256.Sum256([]byte(ip)))[:8] if _, delErr := db.Conn.Exec(r.Context(), ` DELETE FROM time_blockers WHERE (description LIKE 'RESERVATION:admin:%' AND created_by = $1) OR (description LIKE 'RESERVATION:anon:' || $2 || ':%') `, adminID, ipHash); delErr != nil { log.Printf("Failed to delete existing admin reservation: %v", delErr) http.Error(w, "Internal server error", http.StatusInternalServerError) return } blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime, &adminID) if err != nil { log.Printf("Failed to check time blocker overlap: %v", err) } else if blockerOverlap { http.Error(w, "Cannot book this time - slot is blocked", http.StatusConflict) return } tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() // Check booking overlap inside transaction // pending_release is excluded — those bookings are evicted at creation time // by AdminCreateBookingForUserHandler / CreateBookingHandler. overlapRows, err := tx.Query(r.Context(), ` SELECT 1 FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND end_time > $1 FOR UPDATE `, req.StartTime, endTime) if err != nil { log.Printf("Failed to check overlap: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } var cnt int for overlapRows.Next() { cnt++ } overlapRows.Close() if err := overlapRows.Err(); err != nil { log.Printf("Overlap row iteration error: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if cnt > 0 { http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict) return } _, err = tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE description LIKE 'RESERVATION:admin:%' AND created_by = $1 `, adminID) if err != nil { log.Printf("Failed to delete existing admin reservation: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } customerID := "guest" if req.UserID != nil && *req.UserID != "" { customerID = *req.UserID } description := fmt.Sprintf("RESERVATION:admin:%s:%s:%d", req.ReservationType, customerID, clock.Now().UnixNano()) var reservationID string var createdAt time.Time err = tx.QueryRow(r.Context(), ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, $2, $3, $4) RETURNING id, created_at `, req.StartTime, svcDuration, description, adminID).Scan(&reservationID, &createdAt) if err != nil { log.Printf("Failed to create reservation: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } expiresAt := createdAt.Add(time.Duration(req.TTLMinutes) * time.Minute) response := AdminReserveSlotResponse{ ID: reservationID, StartTime: req.StartTime, DurationMinutes: svcDuration, ExpiresAt: expiresAt, TTLMinutes: req.TTLMinutes, } w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(response); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // calculateServiceDurationWithOverrides calculates total duration considering service overrides func calculateServiceDurationWithOverrides(ctx context.Context, serviceIDs []string, overrides []ServiceOverrideRequest) (int, error) { // If no overrides, use simple sum if len(overrides) == 0 { var duration int err := db.Conn.QueryRow(ctx, ` SELECT COALESCE(SUM(dur), 0) FROM ( SELECT duration_minutes AS dur FROM services WHERE id = ANY($1) UNION ALL SELECT duration_minutes FROM custom_services WHERE id = ANY($1) ) combined `, serviceIDs).Scan(&duration) return duration, err } // Build override map overrideMap := make(map[string]int) for _, o := range overrides { if o.OverrideDurationMinutes != nil { overrideMap[o.ServiceID] = *o.OverrideDurationMinutes } } // Get all services rows, err := db.Conn.Query(ctx, ` SELECT id, duration_minutes FROM services WHERE id = ANY($1) UNION ALL SELECT id, duration_minutes FROM custom_services WHERE id = ANY($1) `, serviceIDs) if err != nil { return 0, err } defer rows.Close() var totalDuration int for rows.Next() { var svcID string var baseDuration int if err := rows.Scan(&svcID, &baseDuration); err != nil { return 0, err } if overrideDuration, exists := overrideMap[svcID]; exists { totalDuration += overrideDuration } else { totalDuration += baseDuration } } return totalDuration, rows.Err() }