# Event model representing nightlife events and events # Manages event details, location data, and publication state require "net/http" require "json" class Event < ApplicationRecord # Define states for Event lifecycle management # draft: Initial state when Event is being created # published: Event is visible to public and can be discovered # canceled: Event has been canceled by organizer # sold_out: Event has reached capacity and tickets are no longer available enum :state, { draft: 0, published: 1, canceled: 2, sold_out: 3 }, default: :draft # === Relations === belongs_to :user has_many :ticket_types has_many :tickets, through: :ticket_types has_many :orders has_many :promotion_codes has_one_attached :image # === Callbacks === before_validation :generate_slug, if: :should_generate_slug? before_validation :geocode_address, if: :should_geocode_address? before_update :handle_image_replacement, if: :image_attached? # Validations for Event attributes # Basic information validates :name, presence: true, length: { minimum: 3, maximum: 100 } validates :slug, presence: true, length: { minimum: 3, maximum: 100 } validates :description, presence: true, length: { minimum: 10, maximum: 2000 } validates :state, presence: true, inclusion: { in: states.keys } validate :image_format, if: -> { image.attached? } validate :image_size, if: -> { image.attached? } # Venue information validates :venue_name, presence: true, length: { maximum: 100 } validates :venue_address, presence: true, length: { maximum: 200 } # Geographic coordinates for map display validates :latitude, presence: true, numericality: { greater_than_or_equal_to: -90, less_than_or_equal_to: 90 } validates :longitude, presence: true, numericality: { greater_than_or_equal_to: -180, less_than_or_equal_to: 180 } # Scopes for querying events with common filters scope :featured, -> { where(featured: true) } # Get featured events for homepage scope :published, -> { where(state: :published) } # Get publicly visible events scope :search_by_name, ->(query) { where("name ILIKE ?", "%#{query}%") } # Search by name (case-insensitive) # Scope for published events ordered by start time scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) } # === Instance Methods === # Generate SEO-friendly slug from name, venue name, and city def generate_slug return if name.blank? && venue_name.blank? && venue_address.blank? # Extract city from venue address city = extract_city_from_address(venue_address) # Build slug parts slug_parts = [] slug_parts << name if name.present? slug_parts << venue_name if venue_name.present? slug_parts << city if city.present? # Generate slug using Rails' parameterize slug_value = slug_parts.join('-').parameterize # Ensure minimum length if slug_value.length < 3 slug_value = "event-#{Time.current.to_i}".parameterize end # Make sure slug is unique base_slug = slug_value counter = 1 while Event.where.not(id: id).where(slug: slug_value).exists? slug_value = "#{base_slug}-#{counter}".parameterize counter += 1 end self.slug = slug_value end # Check if slug should be generated def should_generate_slug? # Generate slug if it's blank or if it's a new record slug.blank? || new_record? end # Extract city from address def extract_city_from_address(address) return "" if address.blank? # Look for French postal code pattern (5 digits) + city match = address.match(/(\d{5})\s+([^,]+)/) if match return match[2].strip end # Fallback: extract last part after comma (assume it's city) parts = address.split(',') if parts.length > 1 return parts[parts.length - 1].strip end # Another fallback: look for common French city indicators city_indicators = ["Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Strasbourg", "Montpellier", "Bordeaux", "Lille"] for city in city_indicators if address.downcase.include?(city.downcase) return city end end "" end # Get image URL prioritizing old image field if it exists def display_image_url # First check if old image field exists and has a value return self[:image] if self[:image].present? # Fall back to attached image return nil unless image.attached? # Return the URL for the attached image Rails.application.routes.url_helpers.rails_blob_url(image, only_path: true) end # Get image variants for different display sizes def event_image_variant(size = :medium) # For old image field, return the URL directly return self[:image] if self[:image].present? # For attached images, process variants return nil unless image.attached? case size when :large image.variant(resize_to_limit: [1200, 630]) when :medium image.variant(resize_to_limit: [800, 450]) when :small image.variant(resize_to_limit: [400, 225]) else image end end # Check if event has any image (old field or attached) def has_image? self[:image].present? || image.attached? end # Check if coordinates were successfully geocoded or are fallback coordinates def geocoding_successful? coordinates_look_valid? end # Get a user-friendly status message about geocoding def geocoding_status_message return nil if geocoding_successful? "Les coordonnées exactes n'ont pas pu être déterminées automatiquement. Une localisation approximative a été utilisée." end # Check if ticket booking is currently allowed for this event def booking_allowed? return false unless published? return false if sold_out? return false if canceled? # Check if event has started and if booking during event is disabled if event_started? && !allow_booking_during_event? return false end true end # Check if the event has already started def event_started? return false if start_time.blank? Time.current >= start_time end # Check if the event has ended def event_ended? return false if end_time.blank? Time.current >= end_time end # Check if booking is allowed during the event # This is a simple attribute reader that defaults to false if nil def allow_booking_during_event? !!allow_booking_during_event end # Duplicate an event with all its ticket types def duplicate(clone_ticket_types: true) # Duplicate the event new_event = self.dup new_event.name = "Copie de #{name}" new_event.slug = "#{slug}-copy-#{Time.current.to_i}" new_event.state = :draft new_event.created_at = Time.current new_event.updated_at = Time.current Event.transaction do if new_event.save # Duplicate all ticket types if requested if clone_ticket_types ticket_types.each do |ticket_type| new_ticket_type = ticket_type.dup new_ticket_type.event = new_event new_ticket_type.save! end end new_event else nil end end rescue nil end # Validate image format def image_format return unless image.attached? allowed_types = %w[image/jpeg image/jpg image/png image/webp] unless allowed_types.include?(image.content_type) errors.add(:image, "doit être au format JPG, PNG ou WebP") end end # Validate image size def image_size return unless image.attached? if image.byte_size > 5.megabytes errors.add(:image, "doit faire moins de 5MB") end end private # Check if image is attached for the callback def image_attached? image.attached? end # Handle image replacement when a new image is uploaded def handle_image_replacement # Clear the old image field if a new image is being attached if image.attached? self[:image] = nil end end # Determine if we should perform server-side geocoding def should_geocode_address? # Don't geocode if address is blank return false if venue_address.blank? # Don't geocode if we already have valid coordinates (likely from frontend) return false if coordinates_look_valid? # Only geocode if address changed and we don't have coordinates venue_address_changed? end # Check if the current coordinates look like they were set by frontend geocoding def coordinates_look_valid? return false if latitude.blank? || longitude.blank? lat_f = latitude.to_f lng_f = longitude.to_f # Basic sanity checks for coordinate ranges return false if lat_f < -90 || lat_f > 90 return false if lng_f < -180 || lng_f > 180 # Check if coordinates are not the default fallback coordinates fallback_lat = 46.603354 fallback_lng = 1.888334 # Check if coordinates are not exactly 0,0 (common invalid default) return false if lat_f == 0.0 && lng_f == 0.0 # Coordinates are valid if they're not exactly the fallback coordinates !(lat_f == fallback_lat && lng_f == fallback_lng) end # Automatically geocode address to get latitude and longitude # This only runs when no valid coordinates are provided (fallback for non-JS users) def geocode_address Rails.logger.info "Running server-side geocoding for '#{venue_address}' (no frontend coordinates provided)" # Store original coordinates in case we need to fall back original_lat = latitude original_lng = longitude begin # Use OpenStreetMap Nominatim API for geocoding encoded_address = URI.encode_www_form_component(venue_address.strip) uri = URI("https://nominatim.openstreetmap.org/search?q=#{encoded_address}&format=json&limit=1&addressdetails=1") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true request = Net::HTTP::Get.new(uri) request["User-Agent"] = "AperoNight Event Platform/1.0 (https://aperonight.com)" request["Accept"] = "application/json" response = http.request(request) if response.code == "200" data = JSON.parse(response.body) if data.any? result = data.first self.latitude = result["lat"].to_f.round(6) self.longitude = result["lon"].to_f.round(6) Rails.logger.info "Server-side geocoded '#{venue_address}' to coordinates: #{latitude}, #{longitude}" return end end # If we reach here, geocoding failed handle_geocoding_failure(original_lat, original_lng) rescue => e Rails.logger.error "Server-side geocoding failed for '#{venue_address}': #{e.message}" handle_geocoding_failure(original_lat, original_lng) end end # Handle geocoding failure with fallback strategies def handle_geocoding_failure(original_lat, original_lng) # Strategy 1: Keep existing coordinates if this is an update if original_lat.present? && original_lng.present? self.latitude = original_lat self.longitude = original_lng Rails.logger.warn "Geocoding failed for '#{venue_address}', keeping existing coordinates: #{latitude}, #{longitude}" return end # Strategy 2: Try to extract country/city and use approximate coordinates fallback_coordinates = get_fallback_coordinates_from_address if fallback_coordinates self.latitude = fallback_coordinates[:lat] self.longitude = fallback_coordinates[:lng] Rails.logger.warn "Using fallback coordinates for '#{venue_address}': #{latitude}, #{longitude}" return end # Strategy 3: Use default coordinates (center of France) as last resort # This ensures the event can still be created self.latitude = 46.603354 # Center of France self.longitude = 1.888334 Rails.logger.warn "Using default coordinates for '#{venue_address}' due to geocoding failure: #{latitude}, #{longitude}" end # Extract country/city from address and return approximate coordinates def get_fallback_coordinates_from_address address_lower = venue_address.downcase # Common French cities with approximate coordinates french_cities = { "paris" => { lat: 48.8566, lng: 2.3522 }, "lyon" => { lat: 45.7640, lng: 4.8357 }, "marseille" => { lat: 43.2965, lng: 5.3698 }, "toulouse" => { lat: 43.6047, lng: 1.4442 }, "nice" => { lat: 43.7102, lng: 7.2620 }, "nantes" => { lat: 47.2184, lng: -1.5536 }, "montpellier" => { lat: 43.6110, lng: 3.8767 }, "strasbourg" => { lat: 48.5734, lng: 7.7521 }, "bordeaux" => { lat: 44.8378, lng: -0.5792 }, "lille" => { lat: 50.6292, lng: 3.0573 } } # Check if any known city is mentioned in the address french_cities.each do |city, coords| if address_lower.include?(city) return coords end end # Check for common country indicators if address_lower.include?("france") || address_lower.include?("french") return { lat: 46.603354, lng: 1.888334 } # Center of France end nil end end