feat: Implement event image upload system for promoters
- Add Active Storage migrations for file attachments - Update Event model to handle image uploads with validation - Replace image URL fields with file upload in forms - Add client-side image preview with validation - Update all views to display uploaded images properly - Fix JSON serialization to prevent stack overflow in API - Add custom image validation methods for format and size - Include image processing variants for different display sizes - Fix promotion code test infrastructure and Stripe configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,14 +9,11 @@
|
||||
### Medium Priority
|
||||
|
||||
- [ ] feat: Promoter system with event creation, ticket types creation and metrics display
|
||||
- [ ] feat: Multiple ticket types (early bird, VIP, general admission)
|
||||
- [ ] feat: Refund management system
|
||||
- [ ] feat: Real-time sales analytics dashboard
|
||||
- [ ] feat: Guest checkout without account creation
|
||||
- [ ] feat: Seat selection with interactive venue maps
|
||||
- [ ] feat: Dynamic pricing based on demand
|
||||
- [ ] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
|
||||
- [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
|
||||
- [ ] feat: Payout system for promoters (automated/manual payment processing)
|
||||
- [ ] feat: Platform commission tracking and fee structure display
|
||||
- [ ] feat: Tax reporting and revenue export for promoters
|
||||
@@ -53,7 +50,6 @@
|
||||
|
||||
## 🚧 Doing
|
||||
|
||||
- [ ] feat: Promotion code on ticket
|
||||
- [ ] feat: Page to display all tickets for an event
|
||||
- [ ] feat: Add a link into notification email to order page that display all tickets
|
||||
|
||||
@@ -65,7 +61,11 @@
|
||||
- [x] Add login functionality
|
||||
- [x] refactor: Moving checkout to OrdersController
|
||||
- [x] feat: Payment gateway integration (Stripe) - PayPal not implemented
|
||||
- [x] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
|
||||
- [x] feat: Digital tickets with QR codes
|
||||
- [x] feat: Ticket inventory management and capacity limits
|
||||
- [x] feat: Event discovery with search and filtering
|
||||
- [x] feat: Multiple ticket types (early bird, VIP, general admission)
|
||||
- [x] feat: Email notifications (purchase confirmations, event reminders)
|
||||
- [x] feat: Promotion code on ticket
|
||||
- [x] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
|
||||
|
||||
@@ -14,14 +14,14 @@ module Api
|
||||
# Retrieves all events sorted by creation date (most recent first)
|
||||
def index
|
||||
@events = Event.all.order(created_at: :desc)
|
||||
render json: @events, status: :ok
|
||||
render json: @events.map { |e| event_json(e) }, status: :ok
|
||||
end
|
||||
|
||||
# GET /api/v1/events/:id
|
||||
# Retrieves a single event by its ID
|
||||
# Returns 404 if the event is not found
|
||||
def show
|
||||
render json: @event, status: :ok
|
||||
render json: event_json(@event), status: :ok
|
||||
end
|
||||
|
||||
# POST /api/v1/events
|
||||
@@ -31,7 +31,7 @@ module Api
|
||||
def create
|
||||
@event = Event.new(event_params)
|
||||
if @event.save
|
||||
render json: @event, status: :created
|
||||
render json: event_json(@event), status: :created
|
||||
else
|
||||
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
@@ -43,7 +43,7 @@ module Api
|
||||
# Returns 422 Unprocessable Entity with error messages on failure
|
||||
def update
|
||||
if @event.update(event_params)
|
||||
render json: @event, status: :ok
|
||||
render json: event_json(@event), status: :ok
|
||||
else
|
||||
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
@@ -73,6 +73,32 @@ module Api
|
||||
|
||||
private
|
||||
|
||||
# Helper method to serialize event data safely
|
||||
def event_json(event)
|
||||
{
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
slug: event.slug,
|
||||
description: event.description,
|
||||
state: event.state,
|
||||
venue_name: event.venue_name,
|
||||
venue_address: event.venue_address,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
latitude: event.latitude,
|
||||
longitude: event.longitude,
|
||||
featured: event.featured,
|
||||
created_at: event.created_at,
|
||||
updated_at: event.updated_at,
|
||||
user: {
|
||||
id: event.user.id,
|
||||
email: event.user.email,
|
||||
first_name: event.user.first_name,
|
||||
last_name: event.user.last_name
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
# Finds an event by its ID or returns 404 Not Found
|
||||
# Used as before_action for the show, update, and destroy actions
|
||||
def set_event
|
||||
|
||||
@@ -164,6 +164,8 @@ class OrdersController < ApplicationController
|
||||
flash[:alert] = "Erreur lors de la création de la session de paiement"
|
||||
end
|
||||
end
|
||||
|
||||
render :checkout
|
||||
end
|
||||
|
||||
# Increment payment attempt - called via AJAX when user clicks pay button
|
||||
|
||||
@@ -29,6 +29,8 @@ class Promoter::EventsController < ApplicationController
|
||||
if @event.save
|
||||
redirect_to promoter_event_path(@event), notice: "Event créé avec succès!"
|
||||
else
|
||||
# If validation fails and an image was attached, purge it
|
||||
@event.image.purge if @event.image.attached?
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -664,4 +664,37 @@ export default class extends Controller {
|
||||
this.hideMessage("geocoding-success")
|
||||
this.hideMessage("geocoding-progress")
|
||||
}
|
||||
|
||||
// Preview selected image
|
||||
previewImage(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Veuillez sélectionner une image valide.')
|
||||
event.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file size (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('L\'image ne doit pas dépasser 5MB.')
|
||||
event.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Show preview
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const previewContainer = document.getElementById('image-preview')
|
||||
const previewImg = document.getElementById('preview-img')
|
||||
|
||||
if (previewContainer && previewImg) {
|
||||
previewImg.src = e.target.result
|
||||
previewContainer.classList.remove('hidden')
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ class Event < ApplicationRecord
|
||||
has_many :tickets, through: :ticket_types
|
||||
has_many :orders
|
||||
has_many :promotion_codes
|
||||
has_one_attached :image
|
||||
|
||||
|
||||
# === Callbacks ===
|
||||
before_validation :geocode_address, if: :should_geocode_address?
|
||||
|
||||
@@ -32,7 +34,8 @@ class Event < ApplicationRecord
|
||||
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
|
||||
validate :image_format, if: -> { image.attached? }
|
||||
validate :image_size, if: -> { image.attached? }
|
||||
|
||||
# Venue information
|
||||
validates :venue_name, presence: true, length: { maximum: 100 }
|
||||
@@ -58,6 +61,20 @@ class Event < ApplicationRecord
|
||||
|
||||
# === Instance Methods ===
|
||||
|
||||
# Get image variants for different display sizes
|
||||
def event_image_variant(size = :medium)
|
||||
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 coordinates were successfully geocoded or are fallback coordinates
|
||||
def geocoding_successful?
|
||||
coordinates_look_valid?
|
||||
@@ -131,6 +148,25 @@ class Event < ApplicationRecord
|
||||
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
|
||||
|
||||
# Determine if we should perform server-side geocoding
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%= link_to event_path(event.slug, event), class: "group block p-4 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-purple-300 dark:hover:border-purple-600 hover:shadow-md transition-all duration-200" do %>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-16 h-16 bg-slate-200 dark:bg-slate-700 rounded-lg overflow-hidden flex-shrink-0">
|
||||
<%= image_tag event.image, alt: event.name, class: "w-full h-full object-cover" if event.image.present? %>
|
||||
<%= image_tag event.event_image_variant(:small), alt: event.name, class: "w-full h-full object-cover" if event.image.attached? %>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors duration-200">
|
||||
|
||||
@@ -22,13 +22,9 @@
|
||||
<% @events.each do |event| %>
|
||||
<article class="group bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden transform hover:-translate-y-1">
|
||||
<%= link_to event_path(event.slug, event), class: "block" do %>
|
||||
<% if event.image.present? %>
|
||||
<% if event.image.attached? %>
|
||||
<div class="relative overflow-hidden aspect-[4/3]">
|
||||
<img
|
||||
src="<%= event.image %>"
|
||||
alt="<%= event.name %>"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
>
|
||||
<%= image_tag event.event_image_variant(:medium), alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
|
||||
<!-- Event featured badge -->
|
||||
<% if event.featured? %>
|
||||
<div class="absolute top-4 left-4">
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<!-- Event main wrapper -->
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<!-- Event Header with Image -->
|
||||
<% if @event.image.present? %>
|
||||
<% if @event.image.attached? %>
|
||||
<div class="relative h-96">
|
||||
<%= image_tag @event.image, class: "w-full h-full object-cover" %>
|
||||
<%= image_tag @event.event_image_variant(:large), class: "w-full h-full object-cover" %>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-transparent"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-6 md:p-8">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
|
||||
@@ -89,10 +89,8 @@
|
||||
<div class="bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden">
|
||||
<!-- Event Image -->
|
||||
<div class="relative overflow-hidden aspect-[4/3]">
|
||||
<% if event.image.present? %>
|
||||
<img src="<%= event.image %>"
|
||||
alt="<%= event.name %>"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
|
||||
<% if event.image.attached? %>
|
||||
<%= image_tag event.event_image_variant(:medium), alt: event.name, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
|
||||
<% else %>
|
||||
<div class="w-full h-full bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
|
||||
<i data-lucide="calendar" class="w-16 h-16 text-white"></i>
|
||||
|
||||
@@ -67,9 +67,41 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<%= form.label :image, "Image (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.url_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg" %>
|
||||
<p class="mt-1 text-sm text-gray-500">URL de l'image de couverture de l'événement</p>
|
||||
<%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<div class="space-y-4">
|
||||
<!-- Current image preview -->
|
||||
<% if @event.image.attached? %>
|
||||
<div class="relative">
|
||||
<%= image_tag @event.image.variant(resize_to_limit: [400, 225]), class: "w-full h-48 object-cover rounded-lg border border-gray-200" %>
|
||||
<div class="absolute top-2 right-2">
|
||||
<button type="button" onclick="this.closest('div').querySelector('input[type=file]').click()" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- File upload field -->
|
||||
<div class="relative">
|
||||
<%= form.file_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100", accept: "image/png,image/jpeg,image/jpg,image/webp", data: { action: "change->event-form#previewImage" } %>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
Formats acceptés : PNG, JPG, JPEG, WebP (max 5MB)
|
||||
<% if @event.image.attached? %>
|
||||
<br>Laissez vide pour conserver l'image actuelle
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image preview container -->
|
||||
<div id="image-preview" class="hidden">
|
||||
<div class="relative">
|
||||
<img id="preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
|
||||
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('image-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -60,9 +60,38 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<%= form.label :image, "Image (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.url_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "https://example.com/image.jpg" %>
|
||||
<p class="mt-1 text-sm text-gray-500">URL de l'image de couverture de l'événement</p>
|
||||
<%= form.label :image, "Image de couverture", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<div class="space-y-4">
|
||||
<!-- Current image preview (for edit mode) -->
|
||||
<% if @event.image.attached? %>
|
||||
<div class="relative">
|
||||
<%= image_tag @event.image.variant(resize_to_limit: [400, 225]), class: "w-full h-48 object-cover rounded-lg border border-gray-200" %>
|
||||
<div class="absolute top-2 right-2">
|
||||
<button type="button" onclick="this.closest('div').previousElementSibling.querySelector('input[type=file]').click()" class="bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- File upload field -->
|
||||
<div class="relative">
|
||||
<%= form.file_field :image, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100", accept: "image/png,image/jpeg,image/jpg,image/webp", data: { action: "change->event-form#previewImage" } %>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
Formats acceptés : PNG, JPG, JPEG, WebP (max 5MB)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image preview container -->
|
||||
<div id="image-preview" class="hidden">
|
||||
<div class="relative">
|
||||
<img id="preview-img" src="" alt="Preview" class="w-full h-48 object-cover rounded-lg border border-gray-200">
|
||||
<button type="button" onclick="document.getElementById('event_image').value = ''; document.getElementById('image-preview').classList.add('hidden');" class="absolute top-2 right-2 bg-red-500 text-white p-2 rounded-full hover:bg-red-600 transition-colors">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -174,9 +174,9 @@
|
||||
<!-- Main content -->
|
||||
<div class="lg:col-span-2 space-y-6 lg:space-y-8">
|
||||
<!-- Event image -->
|
||||
<% if @event.image.present? %>
|
||||
<% if @event.image.attached? %>
|
||||
<div class="aspect-video bg-gray-100 rounded-2xl overflow-hidden">
|
||||
<img src="<%= @event.image %>" alt="<%= @event.name %>" class="w-full h-full object-cover">
|
||||
<%= image_tag @event.event_image_variant(:large), alt: @event.name, class: "w-full h-full object-cover" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -50,4 +50,11 @@ Rails.application.configure do
|
||||
|
||||
# Raise error when a before_action's only/except options reference missing actions.
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
# Configure Stripe for testing
|
||||
config.stripe = {
|
||||
publishable_key: "pk_test_test",
|
||||
secret_key: "sk_test_test",
|
||||
signing_secret: "whsec_test_test"
|
||||
}
|
||||
end
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# This migration comes from active_storage (originally 20170806125915)
|
||||
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
# Use Active Record's configured type for primary and foreign keys
|
||||
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
||||
|
||||
create_table :active_storage_blobs, id: primary_key_type do |t|
|
||||
t.string :key, null: false
|
||||
t.string :filename, null: false
|
||||
t.string :content_type
|
||||
t.text :metadata
|
||||
t.string :service_name, null: false
|
||||
t.bigint :byte_size, null: false
|
||||
t.string :checksum
|
||||
|
||||
if connection.supports_datetime_with_precision?
|
||||
t.datetime :created_at, precision: 6, null: false
|
||||
else
|
||||
t.datetime :created_at, null: false
|
||||
end
|
||||
|
||||
t.index [ :key ], unique: true
|
||||
end
|
||||
|
||||
create_table :active_storage_attachments, id: primary_key_type do |t|
|
||||
t.string :name, null: false
|
||||
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||
t.references :blob, null: false, type: foreign_key_type
|
||||
|
||||
if connection.supports_datetime_with_precision?
|
||||
t.datetime :created_at, precision: 6, null: false
|
||||
else
|
||||
t.datetime :created_at, null: false
|
||||
end
|
||||
|
||||
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
|
||||
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||
end
|
||||
|
||||
create_table :active_storage_variant_records, id: primary_key_type do |t|
|
||||
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
|
||||
t.string :variation_digest, null: false
|
||||
|
||||
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
|
||||
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def primary_and_foreign_key_types
|
||||
config = Rails.configuration.generators
|
||||
setting = config.options[config.orm][:primary_key_type]
|
||||
primary_key_type = setting || :primary_key
|
||||
foreign_key_type = setting || :bigint
|
||||
[ primary_key_type, foreign_key_type ]
|
||||
end
|
||||
end
|
||||
4
db/migrate/20250929222616_add_image_to_events.rb
Normal file
4
db/migrate/20250929222616_add_image_to_events.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class AddImageToEvents < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
end
|
||||
end
|
||||
32
db/schema.rb
generated
32
db/schema.rb
generated
@@ -10,7 +10,35 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_29_222616) do
|
||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
t.bigint "record_id", null: false
|
||||
t.bigint "blob_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
|
||||
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "active_storage_blobs", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.string "filename", null: false
|
||||
t.string "content_type"
|
||||
t.text "metadata"
|
||||
t.string "service_name", null: false
|
||||
t.bigint "byte_size", null: false
|
||||
t.string "checksum"
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
|
||||
end
|
||||
|
||||
create_table "active_storage_variant_records", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||
t.bigint "blob_id", null: false
|
||||
t.string "variation_digest", null: false
|
||||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "slug", null: false
|
||||
@@ -130,6 +158,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
end
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "order_promotion_codes", "orders"
|
||||
add_foreign_key "order_promotion_codes", "promotion_codes"
|
||||
add_foreign_key "promotion_codes", "events"
|
||||
|
||||
74
debug_promotion_test.rb
Executable file
74
debug_promotion_test.rb
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
# Debug script to understand the test failure
|
||||
require_relative './config/environment'
|
||||
|
||||
# Load test data
|
||||
user = User.find_by(email: 'user1@example.com')
|
||||
event = Event.find_by(name: 'Summer Concert')
|
||||
|
||||
puts "User: #{user.inspect}"
|
||||
puts "Event: #{event.inspect}"
|
||||
|
||||
# Create a new order for the test
|
||||
order = user.orders.create!(event: event, status: "draft", expires_at: 15.minutes.from_now, total_amount_cents: 2000)
|
||||
puts "Order: #{order.inspect}"
|
||||
|
||||
# Create ticket type and ticket
|
||||
ticket_type = TicketType.create!(
|
||||
name: "Test Ticket Type",
|
||||
description: "A valid description for the ticket type that is long enough",
|
||||
price_cents: 2000,
|
||||
quantity: 10,
|
||||
sale_start_at: Time.current,
|
||||
sale_end_at: Time.current + 1.day,
|
||||
requires_id: false,
|
||||
event: event
|
||||
)
|
||||
|
||||
ticket = Ticket.create!(
|
||||
order: order,
|
||||
ticket_type: ticket_type,
|
||||
status: "draft",
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
price_cents: 2000
|
||||
)
|
||||
|
||||
puts "Ticket: #{ticket.inspect}"
|
||||
puts "Ticket valid?: #{ticket.valid?}"
|
||||
puts "Order tickets count: #{order.tickets.count}"
|
||||
|
||||
# Recalculate the order total
|
||||
order.calculate_total!
|
||||
puts "Order total: #{order.total_amount_cents}"
|
||||
|
||||
# Create a unique promotion code
|
||||
unique_code = "TESTDISCOUNT_#{SecureRandom.hex(4)}"
|
||||
puts "Creating promotion code with code: #{unique_code}"
|
||||
|
||||
promotion_code = PromotionCode.create(
|
||||
code: unique_code,
|
||||
discount_amount_cents: 500,
|
||||
expires_at: 1.month.from_now,
|
||||
active: true,
|
||||
user: user,
|
||||
event: event
|
||||
)
|
||||
|
||||
puts "Promotion code: #{promotion_code.inspect}"
|
||||
puts "Promotion code valid?: #{promotion_code.valid?}"
|
||||
|
||||
# Check if order already has promotion codes
|
||||
puts "Order promotion codes before: #{order.promotion_codes.count}"
|
||||
|
||||
# Try to apply the promotion code
|
||||
begin
|
||||
order.promotion_codes << promotion_code
|
||||
puts "Successfully added promotion code to order"
|
||||
rescue => e
|
||||
puts "Error adding promotion code: #{e.message}"
|
||||
puts e.backtrace.first(5)
|
||||
end
|
||||
|
||||
puts "Order promotion codes after: #{order.promotion_codes.count}"
|
||||
67
debug_test.rb
Executable file
67
debug_test.rb
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
# Debug script to understand the test failure
|
||||
require_relative './config/environment'
|
||||
|
||||
# Load test data
|
||||
user = User.find_by(email: 'user1@example.com')
|
||||
event = Event.find_by(name: 'Summer Concert')
|
||||
order = Order.find_by(status: 'draft')
|
||||
|
||||
puts "User: #{user.inspect}"
|
||||
puts "Event: #{event.inspect}"
|
||||
puts "Order: #{order.inspect}"
|
||||
|
||||
# Check if the user can manage events
|
||||
puts "User can manage events: #{user.can_manage_events?}"
|
||||
|
||||
# Create a promotion code
|
||||
promotion_code = PromotionCode.create(
|
||||
code: "TESTDISCOUNT",
|
||||
discount_amount_cents: 500,
|
||||
expires_at: 1.month.from_now,
|
||||
active: true,
|
||||
user: user,
|
||||
event: event
|
||||
)
|
||||
|
||||
puts "Promotion code: #{promotion_code.inspect}"
|
||||
puts "Promotion code valid?: #{promotion_code.valid_for_use?}"
|
||||
|
||||
# Try to create a ticket type and ticket
|
||||
ticket_type = TicketType.create!(
|
||||
name: "Test Ticket Type",
|
||||
description: "A valid description for the ticket type that is long enough",
|
||||
price_cents: 2000,
|
||||
quantity: 10,
|
||||
sale_start_at: Time.current,
|
||||
sale_end_at: Time.current + 1.day,
|
||||
requires_id: false,
|
||||
event: event
|
||||
)
|
||||
|
||||
puts "Ticket type: #{ticket_type.inspect}"
|
||||
|
||||
# Create ticket with all required fields
|
||||
ticket = Ticket.create!(
|
||||
order: order,
|
||||
ticket_type: ticket_type,
|
||||
status: "draft",
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
price_cents: 2000
|
||||
)
|
||||
|
||||
puts "Ticket: #{ticket.inspect}"
|
||||
puts "Ticket valid?: #{ticket.valid?}"
|
||||
puts "Ticket errors: #{ticket.errors.full_messages}" unless ticket.valid?
|
||||
|
||||
# Recalculate order total
|
||||
order.calculate_total!
|
||||
puts "Order total: #{order.total_amount_cents}"
|
||||
|
||||
# Test the promotion code application
|
||||
puts "Applying promotion code..."
|
||||
order.promotion_codes << promotion_code
|
||||
order.calculate_total!
|
||||
puts "Order total after promotion: #{order.total_amount_cents}"
|
||||
@@ -1,4 +1,5 @@
|
||||
require "test_helper"
|
||||
require "securerandom"
|
||||
|
||||
class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
|
||||
include Devise::Test::IntegrationHelpers
|
||||
@@ -7,7 +8,8 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
|
||||
def setup
|
||||
@user = users(:one)
|
||||
@event = events(:concert_event)
|
||||
@order = orders(:draft_order)
|
||||
# Create a new order for the test to ensure proper associations
|
||||
@order = @user.orders.create!(event: @event, status: "draft", expires_at: 15.minutes.from_now, total_amount_cents: 2000)
|
||||
sign_in @user
|
||||
end
|
||||
|
||||
@@ -25,19 +27,27 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
|
||||
event: @event
|
||||
)
|
||||
|
||||
Ticket.create!(
|
||||
ticket = Ticket.create!(
|
||||
order: @order,
|
||||
ticket_type: ticket_type,
|
||||
status: "draft",
|
||||
first_name: "John",
|
||||
last_name: "Doe"
|
||||
last_name: "Doe",
|
||||
price_cents: 2000
|
||||
)
|
||||
|
||||
# Debug the ticket creation
|
||||
puts "Ticket saved: #{ticket.persisted?}"
|
||||
puts "Ticket errors: #{ticket.errors.full_messages}" unless ticket.valid?
|
||||
puts "Order tickets count: #{@order.tickets.count}"
|
||||
|
||||
# Recalculate the order total
|
||||
@order.calculate_total!
|
||||
|
||||
# Use a unique code for each test run
|
||||
unique_code = "TESTDISCOUNT_#{SecureRandom.hex(4)}"
|
||||
promotion_code = PromotionCode.create(
|
||||
code: "TESTDISCOUNT",
|
||||
code: unique_code,
|
||||
discount_amount_cents: 500, # €5.00
|
||||
expires_at: 1.month.from_now,
|
||||
active: true,
|
||||
@@ -45,7 +55,9 @@ class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
|
||||
event: @event
|
||||
)
|
||||
|
||||
get checkout_order_path(@order), params: { promotion_code: "TESTDISCOUNT" }
|
||||
get checkout_order_path(@order), params: { promotion_code: unique_code }
|
||||
puts "Response status: #{response.status}"
|
||||
puts "Response body: #{response.body}" if response.status != 200
|
||||
assert_response :success
|
||||
assert_not_nil flash.now[:notice]
|
||||
assert_match /Code promotionnel appliqué: TESTDISCOUNT/, flash.now[:notice]
|
||||
|
||||
1
test/fixtures/users.yml
vendored
1
test/fixtures/users.yml
vendored
@@ -5,6 +5,7 @@ one:
|
||||
encrypted_password: <%= Devise::Encryptor.digest(User, 'password123') %>
|
||||
last_name: Trump
|
||||
first_name: Donald
|
||||
is_professionnal: true
|
||||
onboarding_completed: true
|
||||
|
||||
two:
|
||||
|
||||
@@ -19,6 +19,14 @@ module ActiveSupport
|
||||
|
||||
# Add more helper methods to be used by all tests here...
|
||||
|
||||
# Mock Stripe for tests
|
||||
setup do
|
||||
# Mock Stripe checkout session creation
|
||||
Stripe::Checkout::Session.stubs(:create).returns(
|
||||
Struct.new(:id, :url).new("cs_test_session", "https://checkout.stripe.com/test")
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to create users with completed onboarding by default for tests
|
||||
def create_test_user(attributes = {})
|
||||
User.create!({
|
||||
|
||||
Reference in New Issue
Block a user