# 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 enum :payout_status, { pending_request: 0, requested: 1, processing: 2, completed: 3, failed: 4 }, default: :pending_request # === Relations === belongs_to :user has_many :ticket_types has_many :tickets, through: :ticket_types has_many :orders has_many :earnings, dependent: :destroy has_many :payouts, dependent: :destroy # === Callbacks === before_validation :geocode_address, if: :should_geocode_address? # === Validations === # 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 } validates :image, length: { maximum: 500 } # URL or path to image # 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) } # Scope for events eligible for payout scope :eligible_for_payout, -> { where("end_time <= ?", Time.current).joins(:earnings).group("events.id").having("SUM(earnings.amount_cents) > 0") } # === Instance Methods === # Payout methods def total_gross_cents tickets.active.sum(:price_cents) end def total_fees_cents earnings.pending.sum(:fee_cents) end def net_earnings_cents total_gross_cents - total_fees_cents end def can_request_payout?(user = self.user) event_ended? && (net_earnings_cents > 0) && user.is_professionnal? && payouts.pending.empty? 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 private # 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