# 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') %>