package bookings import ( "context" "crussell/db" "crussell/handlers/scheduling" "crussell/mw" "database/sql" "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"` 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) } // 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 { http.Error(w, "At least one 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 svcDuration, err = calculateServiceDurationWithOverrides(r.Context(), req.ServiceIDs, 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 } } weekday := int(req.StartTime.Weekday()) var closeStr string if err := db.DB.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil { if errors.Is(err, sql.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 } endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute) closeTime, _ := time.Parse("15:04:05", closeStr) if endTime.Hour() > closeTime.Hour() || (endTime.Hour() == closeTime.Hour() && endTime.Minute() > closeTime.Minute()) { http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest) return } var cnt int db.DB.QueryRow(r.Context(), ` SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed') AND start_time < $2 AND start_time + (INTERVAL '1 minute' * ( SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id )) > $1 `, req.StartTime, endTime).Scan(&cnt) if cnt > 0 { http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict) return } blockerOverlap, blockerDesc, 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, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict) return } _, err = db.DB.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 = db.DB.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 } 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.DB.QueryRow(ctx, ` SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1) `, 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.DB.Query(ctx, ` SELECT id, duration_minutes FROM 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() }