Files
aperonight/docs/checkin-system-implementation.md

27 KiB

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:

# 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:

# 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:

# 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:

# 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:

# 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

# 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:

<!-- app/views/checkin/show.html.erb -->
<div class="checkin-page">
  <!-- Header -->
  <div class="checkin-header">
    <h1>Check-in Scanner</h1>
    <p class="event-info">
      <strong><%= @event.name %></strong><br>
      <%= @event.start_time.strftime('%d %B %Y à %H:%M') %>
    </p>
  </div>

  <!-- Statistics Dashboard -->
  <div class="stats-dashboard" id="stats-dashboard">
    <div class="stat-card">
      <span class="stat-number" id="checked-in-count"><%= @checked_in_count %></span>
      <span class="stat-label">Entrées validées</span>
    </div>
    <div class="stat-card">
      <span class="stat-number" id="total-tickets"><%= @total_tickets %></span>
      <span class="stat-label">Total billets</span>
    </div>
    <div class="stat-card">
      <span class="stat-number" id="remaining-tickets"><%= @remaining_tickets %></span>
      <span class="stat-label">En attente</span>
    </div>
    <div class="stat-card">
      <span class="stat-number" id="checkin-rate">0%</span>
      <span class="stat-label">Taux d'entrée</span>
    </div>
  </div>

  <!-- Scanner Section -->
  <div class="scanner-section">
    <div id="qr-scanner" class="scanner-viewport"></div>
    
    <div class="scanner-controls">
      <button id="start-scan" class="btn btn-primary">
        <i data-lucide="camera"></i>
        Démarrer le scanner
      </button>
      <button id="stop-scan" class="btn btn-secondary" style="display:none;">
        <i data-lucide="camera-off"></i>
        Arrêter le scanner
      </button>
      <button id="switch-camera" class="btn btn-outline" style="display:none;">
        <i data-lucide="rotate-3d"></i>
        Changer caméra
      </button>
    </div>
  </div>

  <!-- Scan Result -->
  <div id="scan-result" class="scan-result" style="display:none;"></div>

  <!-- Recent Check-ins -->
  <div class="recent-checkins">
    <h3>Dernières entrées</h3>
    <div id="recent-list" class="checkin-list">
      <!-- Populated via JavaScript -->
    </div>
  </div>

  <!-- Export Options -->
  <div class="export-section">
    <a href="<%= checkin_export_path(@event, format: :csv) %>" class="btn btn-outline">
      <i data-lucide="download"></i>
      Exporter en CSV
    </a>
  </div>
</div>

<!-- Include QR Scanner Library -->
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>

<script>
class CheckinScanner {
  constructor() {
    this.html5QrCode = null;
    this.isScanning = false;
    this.cameras = [];
    this.currentCameraIndex = 0;
    this.statsUpdateInterval = null;
    
    this.initializeScanner();
    this.startStatsPolling();
  }

  initializeScanner() {
    // Get available cameras
    Html5Qrcode.getCameras().then(devices => {
      this.cameras = devices;
      if (devices && devices.length) {
        this.setupScannerControls();
      }
    }).catch(err => {
      console.error("Camera enumeration error:", err);
    });

    // Event listeners
    document.getElementById('start-scan').addEventListener('click', () => this.startScanning());
    document.getElementById('stop-scan').addEventListener('click', () => this.stopScanning());
    document.getElementById('switch-camera').addEventListener('click', () => this.switchCamera());
  }

  setupScannerControls() {
    if (this.cameras.length > 1) {
      document.getElementById('switch-camera').style.display = 'inline-block';
    }
  }

  startScanning() {
    if (this.isScanning) return;

    this.html5QrCode = new Html5Qrcode("qr-scanner");
    
    const cameraId = this.cameras[this.currentCameraIndex]?.id || { facingMode: "environment" };
    
    this.html5QrCode.start(
      cameraId,
      {
        fps: 10,
        qrbox: { width: 250, height: 250 },
        aspectRatio: 1.0
      },
      (decodedText, decodedResult) => this.onScanSuccess(decodedText, decodedResult),
      (errorMessage) => {} // Silent error handling
    ).then(() => {
      this.isScanning = true;
      this.updateScannerUI();
    }).catch(err => {
      console.error("Scanner start error:", err);
      this.showError("Impossible de démarrer la caméra");
    });
  }

  stopScanning() {
    if (!this.isScanning || !this.html5QrCode) return;

    this.html5QrCode.stop().then(() => {
      this.isScanning = false;
      this.updateScannerUI();
    }).catch(err => {
      console.error("Scanner stop error:", err);
    });
  }

  switchCamera() {
    if (!this.isScanning || this.cameras.length <= 1) return;

    this.stopScanning();
    setTimeout(() => {
      this.currentCameraIndex = (this.currentCameraIndex + 1) % this.cameras.length;
      this.startScanning();
    }, 500);
  }

  updateScannerUI() {
    document.getElementById('start-scan').style.display = this.isScanning ? 'none' : 'inline-block';
    document.getElementById('stop-scan').style.display = this.isScanning ? 'inline-block' : 'none';
    document.getElementById('switch-camera').style.display = 
      (this.isScanning && this.cameras.length > 1) ? 'inline-block' : 'none';
  }

  onScanSuccess(decodedText, decodedResult) {
    // Temporary pause scanning to prevent duplicate scans
    this.stopScanning();
    
    // Process scan
    this.processScan(decodedText);
  }

  processScan(qrData) {
    fetch('<%= checkin_scan_path(@event) %>', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
      },
      body: JSON.stringify({ qr_data: qrData })
    })
    .then(response => response.json())
    .then(data => {
      this.displayScanResult(data);
      if (data.success) {
        this.updateStats(data.stats);
        this.updateRecentCheckins();
      }
      // Resume scanning after 2 seconds
      setTimeout(() => this.startScanning(), 2000);
    })
    .catch(error => {
      console.error('Scan processing error:', error);
      this.showError('Erreur de connexion');
      setTimeout(() => this.startScanning(), 2000);
    });
  }

  displayScanResult(result) {
    const resultDiv = document.getElementById('scan-result');
    const resultClass = result.success ? 'success' : 'error';
    
    let content = `
      <div class="alert ${resultClass}">
        <div class="result-header">
          ${result.success ? '✅' : '❌'}
          <span class="result-message">${result.message}</span>
        </div>`;
        
    if (result.ticket) {
      content += `
        <div class="ticket-details">
          <p><strong>${result.ticket.holder_name}</strong></p>
          <p>${result.ticket.ticket_type} - ${result.ticket.price}</p>
          ${result.ticket.checked_in_at ? `<p class="checkin-time">Entrée validée à ${result.ticket.checked_in_at}</p>` : ''}
        </div>`;
    }
    
    if (result.details) {
      content += `
        <div class="error-details">
          ${result.details.checked_in_at ? `<p>Déjà utilisé le ${result.details.checked_in_at}</p>` : ''}
          ${result.details.checked_in_by ? `<p>Par: ${result.details.checked_in_by}</p>` : ''}
        </div>`;
    }
    
    content += '</div>';
    
    resultDiv.innerHTML = content;
    resultDiv.style.display = 'block';
    
    // Auto-hide after 3 seconds
    setTimeout(() => {
      resultDiv.style.display = 'none';
    }, 3000);

    // Play sound feedback
    this.playFeedbackSound(result.success);
  }

  playFeedbackSound(success) {
    // Create audio feedback for scan results
    const audioContext = new (window.AudioContext || window.webkitAudioContext)();
    const oscillator = audioContext.createOscillator();
    const gainNode = audioContext.createGain();
    
    oscillator.connect(gainNode);
    gainNode.connect(audioContext.destination);
    
    oscillator.frequency.value = success ? 800 : 400; // Higher pitch for success
    oscillator.type = 'sine';
    
    gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
    gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
    
    oscillator.start(audioContext.currentTime);
    oscillator.stop(audioContext.currentTime + 0.3);
  }

  updateStats(stats) {
    if (!stats) return;
    
    document.getElementById('checked-in-count').textContent = stats.checked_in;
    document.getElementById('total-tickets').textContent = stats.total;
    document.getElementById('remaining-tickets').textContent = stats.remaining;
    
    const rate = stats.total > 0 ? Math.round((stats.checked_in / stats.total) * 100) : 0;
    document.getElementById('checkin-rate').textContent = rate + '%';
  }

  startStatsPolling() {
    // Update stats every 30 seconds
    this.statsUpdateInterval = setInterval(() => {
      this.fetchLatestStats();
    }, 30000);
  }

  fetchLatestStats() {
    fetch('<%= checkin_stats_path(@event) %>')
      .then(response => response.json())
      .then(data => {
        this.updateStats(data);
        this.displayRecentCheckins(data.recent_checkins);
      })
      .catch(error => console.error('Stats update error:', error));
  }

  updateRecentCheckins() {
    this.fetchLatestStats();
  }

  displayRecentCheckins(checkins) {
    if (!checkins) return;
    
    const recentList = document.getElementById('recent-list');
    recentList.innerHTML = checkins.map(checkin => `
      <div class="checkin-item">
        <div class="checkin-name">${checkin.holder_name}</div>
        <div class="checkin-details">
          ${checkin.ticket_type} • ${checkin.checked_in_at}
        </div>
      </div>
    `).join('');
  }

  showError(message) {
    this.displayScanResult({
      success: false,
      message: message
    });
  }
}

// Initialize scanner when page loads
document.addEventListener('DOMContentLoaded', function() {
  lucide.createIcons(); // Initialize Lucide icons
  new CheckinScanner();
});
</script>

<style>
.checkin-page {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

.checkin-header {
  text-align: center;
  margin-bottom: 30px;
}

.checkin-header h1 {
  color: #1a1a1a;
  margin-bottom: 10px;
}

.event-info {
  color: #666;
  font-size: 16px;
}

.stats-dashboard {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 15px;
  margin-bottom: 30px;
}

.stat-card {
  background: white;
  border: 1px solid #e5e5e5;
  border-radius: 8px;
  padding: 20px;
  text-align: center;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.stat-number {
  display: block;
  font-size: 2em;
  font-weight: bold;
  color: #2563eb;
}

.stat-label {
  display: block;
  font-size: 0.9em;
  color: #666;
  margin-top: 5px;
}

.scanner-section {
  background: white;
  border-radius: 12px;
  padding: 20px;
  box-shadow: 0 4px 6px rgba(0,0,0,0.1);
  margin-bottom: 30px;
}

#qr-scanner {
  width: 100%;
  max-width: 400px;
  height: 300px;
  margin: 0 auto 20px;
  border: 2px solid #e5e5e5;
  border-radius: 8px;
  background: #f8f9fa;
}

.scanner-controls {
  display: flex;
  gap: 10px;
  justify-content: center;
  flex-wrap: wrap;
}

.btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 12px 24px;
  border: none;
  border-radius: 6px;
  font-size: 16px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s;
  text-decoration: none;
}

.btn-primary {
  background: #2563eb;
  color: white;
}

.btn-primary:hover {
  background: #1d4ed8;
}

.btn-secondary {
  background: #6b7280;
  color: white;
}

.btn-outline {
  background: transparent;
  color: #374151;
  border: 1px solid #d1d5db;
}

.scan-result {
  margin: 20px 0;
}

.alert {
  padding: 20px;
  border-radius: 8px;
  margin-bottom: 20px;
}

.alert.success {
  background: #dcfce7;
  border: 1px solid #bbf7d0;
  color: #166534;
}

.alert.error {
  background: #fef2f2;
  border: 1px solid #fecaca;
  color: #dc2626;
}

.result-header {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 1.1em;
  font-weight: 600;
  margin-bottom: 10px;
}

.ticket-details, .error-details {
  font-size: 0.95em;
  line-height: 1.4;
}

.checkin-time {
  font-weight: 500;
  color: #059669;
}

.recent-checkins {
  background: white;
  border-radius: 8px;
  padding: 20px;
  margin-bottom: 20px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}

.recent-checkins h3 {
  margin: 0 0 15px 0;
  color: #1a1a1a;
}

.checkin-list {
  max-height: 200px;
  overflow-y: auto;
}

.checkin-item {
  padding: 10px 0;
  border-bottom: 1px solid #f0f0f0;
}

.checkin-item:last-child {
  border-bottom: none;
}

.checkin-name {
  font-weight: 500;
  color: #1a1a1a;
}

.checkin-details {
  font-size: 0.9em;
  color: #666;
}

.export-section {
  text-align: center;
  padding: 20px 0;
}

/* Mobile optimizations */
@media (max-width: 768px) {
  .checkin-page {
    padding: 15px;
  }
  
  .stats-dashboard {
    grid-template-columns: repeat(2, 1fr);
  }
  
  .scanner-controls {
    flex-direction: column;
  }
  
  .btn {
    width: 100%;
    justify-content: center;
  }
}

/* Dark mode support */
@media (prefers-color-scheme: dark) {
  .checkin-page {
    background: #1a1a1a;
    color: white;
  }
  
  .stat-card, .scanner-section, .recent-checkins {
    background: #2d2d2d;
    border-color: #404040;
  }
  
  #qr-scanner {
    background: #1a1a1a;
    border-color: #404040;
  }
}
</style>

Security Considerations

1. Authentication & Authorization

  • Only staff/admin users can access check-in interface
  • Event organizers can only check-in for their own events
  • Session-based authentication with CSRF protection

2. QR Code Security

  • QR codes contain multiple validation fields (ticket_id, qr_code, event_id, user_id)
  • Server-side validation of all QR code components
  • Prevention of replay attacks through status tracking

3. Data Privacy

  • Minimal device information collection
  • GDPR-compliant data handling
  • Optional location tracking

4. Rate Limiting

# Add to ApplicationController or CheckinController
before_action :check_scan_rate_limit, only: [:scan]

private

def check_scan_rate_limit
  key = "checkin_scan_#{current_user.id}_#{request.remote_ip}"
  
  if Rails.cache.read(key).to_i > 10 # Max 10 scans per minute
    render json: { 
      success: false, 
      message: "Trop de tentatives. Veuillez patienter." 
    }, status: :too_many_requests
    return
  end
  
  Rails.cache.write(key, Rails.cache.read(key).to_i + 1, expires_in: 1.minute)
end

Testing Strategy

1. Unit Tests

# test/models/ticket_test.rb
test "should check in valid ticket" do
  ticket = create(:ticket, status: "active")
  assert ticket.can_check_in?
  assert ticket.check_in!("staff@example.com")
  assert ticket.checked_in?
  assert_equal "used", ticket.status
end

test "should not check in already used ticket" do
  ticket = create(:ticket, :checked_in)
  refute ticket.can_check_in?
  refute ticket.check_in!("staff@example.com")
end

2. Integration Tests

# test/controllers/checkin_controller_test.rb
test "should scan valid QR code" do
  ticket = create(:ticket, :active)
  qr_data = {
    ticket_id: ticket.id,
    qr_code: ticket.qr_code,
    event_id: ticket.event.id,
    user_id: ticket.user.id
  }

  post checkin_scan_path(ticket.event), 
       params: { qr_data: qr_data.to_json },
       headers: authenticated_headers

  assert_response :success
  assert_equal true, response.parsed_body["success"]
  assert ticket.reload.checked_in?
end

3. System Tests

# test/system/checkin_test.rb
test "staff can scan QR codes" do
  staff_user = create(:user, :staff)
  event = create(:event)
  ticket = create(:ticket, event: event, status: "active")

  login_as(staff_user)
  visit checkin_path(event)
  
  # Simulate QR code scan
  execute_script("window.mockQRScan('#{ticket.qr_code}')")
  
  assert_text "Entrée validée avec succès"
  assert ticket.reload.checked_in?
end

Deployment Checklist

1. Database Migration

  • Run migration to add check-in fields
  • Update production database schema
  • Verify indexes are created

2. Environment Setup

  • Configure user roles (staff/admin)
  • Set up SSL/HTTPS for camera access
  • Test camera permissions on target devices

3. Performance Optimization

  • Add database indexes for check-in queries
  • Implement caching for event statistics
  • Set up monitoring for scan endpoint

4. Mobile Testing

  • Test on iOS Safari
  • Test on Android Chrome
  • Verify camera switching works
  • Test in low-light conditions

Monitoring & Analytics

1. Key Metrics

  • Check-in success rate
  • Average check-in time
  • Device/browser compatibility
  • Peak usage periods

2. Error Tracking

  • Failed scan attempts
  • Camera access denials
  • Network connectivity issues
  • Invalid QR code submissions

3. Reporting

  • Daily check-in summaries
  • Event-specific statistics
  • Staff performance metrics
  • Device usage analytics

Future Enhancements

1. Offline Support

  • Progressive Web App (PWA) implementation
  • Service worker for offline scanning
  • Data synchronization when online

2. Advanced Features

  • Bulk check-in for groups
  • Photo capture for security
  • Real-time dashboard for event managers
  • Integration with access control systems

3. Mobile App

  • Native iOS/Android application
  • Better camera performance
  • Push notifications
  • Barcode scanner integration

Troubleshooting Guide

Common Issues

Camera Not Working

  • Ensure HTTPS connection
  • Check browser permissions
  • Try different camera (front/back)
  • Clear browser cache

QR Code Not Scanning

  • Improve lighting conditions
  • Clean camera lens
  • Hold steady for 2-3 seconds
  • Try manual ticket lookup

Scan Validation Errors

  • Verify ticket is for correct event
  • Check ticket status (active vs used)
  • Confirm ticket hasn't expired
  • Validate QR code format

Performance Issues

  • Monitor database query performance
  • Check network connectivity
  • Review server logs for errors
  • Optimize JavaScript execution

This implementation provides a complete, production-ready check-in system with camera-based QR code scanning, real-time statistics, and comprehensive error handling.