diff --git a/BACKLOG.md b/BACKLOG.md index 0cc8cff..7013ae8 100755 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -31,7 +31,6 @@ ## 🚧 Doing -- [ ] feat: Implement user dashboard to display past events, tickets and orders ## ✅ Done @@ -42,5 +41,5 @@ - [x] refactor: Moving checkout to OrdersController - [x] feat: Payment gateway integration (Stripe) - PayPal not implemented - [x] feat: Digital tickets with QR codes -- [x] feat: Ticket inventory management and capacity limits +- [x] feat: Ticket inventory management and capacity limits - [x] feat: Event discovery with search and filtering diff --git a/docs/checkin-system-implementation.md b/docs/checkin-system-implementation.md new file mode 100644 index 0000000..8c9864f --- /dev/null +++ b/docs/checkin-system-implementation.md @@ -0,0 +1,1092 @@ +# Check-in System Implementation Guide + +## Overview + +The check-in system allows event staff to scan QR codes from tickets using smartphone cameras to validate entry and prevent duplicate access. This document provides a complete implementation guide for the QR code-based check-in system. + +## Architecture + +``` +[Staff Mobile Device] → [Web Scanner Interface] → [Rails Backend] → [Database] + ↓ +[QR Code Scan] → [Validation] → [Check-in Status Update] → [Real-time Feedback] +``` + +## Implementation Steps + +### 1. Database Schema Updates + +Create migration to add check-in fields to tickets: + +```ruby +# db/migrate/add_checkin_fields_to_tickets.rb +class AddCheckinFieldsToTickets < ActiveRecord::Migration[8.0] + def change + add_column :tickets, :checked_in_at, :datetime + add_column :tickets, :checked_in_by, :string + add_column :tickets, :checkin_location, :string # Optional: track location + add_column :tickets, :checkin_device_info, :text # Optional: device fingerprinting + + add_index :tickets, :checked_in_at + add_index :tickets, [:event_id, :checked_in_at] # For event-specific reporting + end +end +``` + +### 2. Model Updates + +Update the Ticket model with check-in functionality: + +```ruby +# app/models/ticket.rb +class Ticket < ApplicationRecord + # ... existing code ... + + # Check-in status methods + def checked_in? + checked_in_at.present? + end + + def can_check_in? + status == "active" && !checked_in? && !expired? + end + + def check_in!(staff_identifier = nil, location: nil, device_info: nil) + return false unless can_check_in? + + update!( + checked_in_at: Time.current, + checked_in_by: staff_identifier, + checkin_location: location, + checkin_device_info: device_info, + status: "used" + ) + + # Optional: Log check-in event + Rails.logger.info "Ticket #{id} checked in by #{staff_identifier} at #{Time.current}" + + true + rescue => e + Rails.logger.error "Check-in failed for ticket #{id}: #{e.message}" + false + end + + def check_in_summary + return "Non utilisé" unless checked_in? + "Utilisé le #{checked_in_at.strftime('%d/%m/%Y à %H:%M')} par #{checked_in_by}" + end + + # Scopes for reporting + scope :checked_in, -> { where.not(checked_in_at: nil) } + scope :not_checked_in, -> { where(checked_in_at: nil) } + scope :checked_in_today, -> { where(checked_in_at: Date.current.beginning_of_day..Date.current.end_of_day) } +end +``` + +### 3. Controller Implementation + +Create the check-in controller: + +```ruby +# app/controllers/checkin_controller.rb +class CheckinController < ApplicationController + include StaffAccess + + before_action :authenticate_user! + before_action :ensure_staff_access + before_action :set_event, only: [:show, :scan, :stats] + + # GET /events/:event_id/checkin + def show + @total_tickets = @event.tickets.active.count + @checked_in_count = @event.tickets.checked_in.count + @remaining_tickets = @total_tickets - @checked_in_count + end + + # POST /events/:event_id/checkin/scan + def scan + begin + # Parse QR code data + qr_data = JSON.parse(params[:qr_data]) + validate_qr_structure(qr_data) + + # Find ticket + ticket = find_ticket_by_qr(qr_data) + return render_error("Billet non trouvé ou invalide") unless ticket + + # Validate event match + return render_error("Billet non valide pour cet événement") unless ticket.event == @event + + # Check ticket status + return handle_ticket_validation(ticket) + + rescue JSON::ParserError + render_error("Format QR Code invalide") + rescue => e + Rails.logger.error "Check-in scan error: #{e.message}" + render_error("Erreur système lors de la validation") + end + end + + # GET /events/:event_id/checkin/stats + def stats + render json: { + total_tickets: @event.tickets.active.count, + checked_in: @event.tickets.checked_in.count, + pending: @event.tickets.not_checked_in.active.count, + checkin_rate: calculate_checkin_rate, + recent_checkins: recent_checkins_data + } + end + + # GET /events/:event_id/checkin/export + def export + respond_to do |format| + format.csv do + send_data generate_checkin_csv, + filename: "checkin_report_#{@event.slug}_#{Date.current}.csv" + end + end + end + + private + + def set_event + @event = Event.find(params[:event_id]) + rescue ActiveRecord::RecordNotFound + redirect_to root_path, alert: "Événement non trouvé" + end + + def validate_qr_structure(qr_data) + required_fields = %w[ticket_id qr_code event_id user_id] + missing_fields = required_fields - qr_data.keys.map(&:to_s) + + if missing_fields.any? + raise "QR Code structure invalide - champs manquants: #{missing_fields.join(', ')}" + end + end + + def find_ticket_by_qr(qr_data) + Ticket.find_by( + id: qr_data["ticket_id"], + qr_code: qr_data["qr_code"] + ) + end + + def handle_ticket_validation(ticket) + if ticket.checked_in? + render_error( + "Billet déjà utilisé", + details: { + checked_in_at: ticket.checked_in_at.strftime('%d/%m/%Y à %H:%M'), + checked_in_by: ticket.checked_in_by + } + ) + elsif !ticket.can_check_in? + render_error("Billet non valide pour l'entrée (statut: #{ticket.status})") + else + perform_checkin(ticket) + end + end + + def perform_checkin(ticket) + device_info = extract_device_info(request) + + if ticket.check_in!(current_user.email, device_info: device_info) + render json: { + success: true, + message: "✅ Entrée validée avec succès", + ticket: ticket_summary(ticket), + stats: current_event_stats + } + else + render_error("Échec de l'enregistrement de l'entrée") + end + end + + def render_error(message, details: {}) + render json: { + success: false, + message: message, + details: details + }, status: :unprocessable_entity + end + + def ticket_summary(ticket) + { + id: ticket.id, + holder_name: "#{ticket.first_name} #{ticket.last_name}", + event_name: ticket.event.name, + ticket_type: ticket.ticket_type.name, + price: "€#{ticket.price_euros}", + checked_in_at: ticket.checked_in_at&.strftime('%H:%M') + } + end + + def current_event_stats + { + total: @event.tickets.active.count, + checked_in: @event.tickets.checked_in.count, + remaining: @event.tickets.not_checked_in.active.count + } + end + + def extract_device_info(request) + { + user_agent: request.user_agent, + ip_address: request.remote_ip, + timestamp: Time.current.iso8601 + }.to_json + end + + def calculate_checkin_rate + total = @event.tickets.active.count + return 0 if total.zero? + ((@event.tickets.checked_in.count.to_f / total) * 100).round(1) + end + + def recent_checkins_data + @event.tickets + .checked_in + .order(checked_in_at: :desc) + .limit(5) + .map { |t| ticket_summary(t) } + end + + def generate_checkin_csv + CSV.generate(headers: true) do |csv| + csv << ["Ticket ID", "Nom", "Prénom", "Type de billet", "Prix", "Status", "Check-in", "Check-in par"] + + @event.tickets.includes(:ticket_type).each do |ticket| + csv << [ + ticket.id, + ticket.last_name, + ticket.first_name, + ticket.ticket_type.name, + "€#{ticket.price_euros}", + ticket.status, + ticket.checked_in? ? ticket.checked_in_at.strftime('%d/%m/%Y %H:%M') : "Non utilisé", + ticket.checked_in_by || "-" + ] + end + end + end +end +``` + +### 4. Staff Access Control + +Create staff access concern: + +```ruby +# app/controllers/concerns/staff_access.rb +module StaffAccess + extend ActiveSupport::Concern + + private + + def ensure_staff_access + unless current_user_has_staff_access? + redirect_to root_path, alert: "Accès non autorisé - réservé au personnel" + end + end + + def current_user_has_staff_access? + return false unless current_user + + # Check if user is staff/admin or event organizer + current_user.staff? || + current_user.admin? || + (@event&.user == current_user) + end +end +``` + +Add role field to User model: + +```ruby +# Migration +class AddRoleToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :role, :integer, default: 0 + add_index :users, :role + end +end + +# app/models/user.rb +class User < ApplicationRecord + enum role: { user: 0, staff: 1, admin: 2 } + + def can_manage_checkins_for?(event) + admin? || staff? || event.user == self + end +end +``` + +### 5. Routes Configuration + +```ruby +# config/routes.rb +Rails.application.routes.draw do + resources :events do + scope module: :events do + get 'checkin', to: 'checkin#show' + post 'checkin/scan', to: 'checkin#scan' + get 'checkin/stats', to: 'checkin#stats' + get 'checkin/export', to: 'checkin#export' + end + end +end +``` + +### 6. Frontend Implementation + +Create the scanner interface: + +```erb + +
+ <%= @event.name %>
+ <%= @event.start_time.strftime('%d %B %Y Ă %H:%M') %>
+