package bookings import ( "context" "crussell/db" "github.com/jackc/pgx/v5" "crussell/handlers/scheduling" "crussell/mw" "encoding/json" "errors" "fmt" "log" "net/http" "time" ) // 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(time.Now()) { http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) return } var err error allIDs := append(req.ServiceIDs, 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 := time.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) var closeStr string if err := db.Conn.QueryRow(r.Context(), `SELECT end_time FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Not open on this day", http.StatusBadRequest) return } log.Printf("Failed to get hours: %v", err) http.Error(w, "Could not verify hours", http.StatusInternalServerError) return } localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute) closeTime, _ := time.Parse("15:04:05", closeStr) if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.Minute() > closeTime.Minute()) { http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest) return } } var cnt int if err := db.Conn.QueryRow(r.Context(), ` SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND end_time > $1 `, req.StartTime, endTime).Scan(&cnt); err != nil { log.Printf("Failed to check overlap: %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 } blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime) 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 tx.Rollback(r.Context()) _, 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, time.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.Header().Set("Content-Type", "application/json") 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, 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() }