- #17: Amélioration gestion des secteurs et statistiques - #18: Optimisation services API et logs - #19: Corrections Flutter widgets et repositories - #20: Fix création passage - détection automatique ope_users.id vs users.id Suppression dossier web/ (migration vers app Flutter) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
669 lines
21 KiB
PHP
669 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use ClientDetector;
|
|
use Session;
|
|
|
|
/**
|
|
* EventLogService - Système de logs d'événements JSONL
|
|
*
|
|
* Enregistre tous les événements métier (authentification, CRUD passages/secteurs/users/entités/opérations)
|
|
* dans des fichiers JSONL quotidiens pour statistiques et audit.
|
|
*
|
|
* Format : Un événement = une ligne JSON
|
|
* Stockage : /logs/events/YYYY-MM-DD.jsonl
|
|
* Rétention : 15 mois (compression après 30 jours)
|
|
*
|
|
* @see docs/EVENTS-LOG.md
|
|
*/
|
|
class EventLogService
|
|
{
|
|
/** @var string Chemin du dossier de logs d'événements */
|
|
private const LOG_DIR = __DIR__ . '/../../logs/events';
|
|
|
|
/** @var int Permissions du dossier */
|
|
private const DIR_PERMISSIONS = 0750;
|
|
|
|
/** @var int Permissions des fichiers */
|
|
private const FILE_PERMISSIONS = 0640;
|
|
|
|
// ==================== MÉTHODES D'AUTHENTIFICATION ====================
|
|
|
|
/**
|
|
* Log une connexion réussie
|
|
*
|
|
* @param int $userId ID utilisateur (users.id)
|
|
* @param int|null $entityId ID entité
|
|
* @param string $username Nom d'utilisateur
|
|
*/
|
|
public static function logLoginSuccess(int $userId, ?int $entityId, string $username): void
|
|
{
|
|
self::writeEvent('login_success', [
|
|
'user_id' => $userId,
|
|
'entity_id' => $entityId,
|
|
'username' => $username
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log une tentative de connexion échouée
|
|
*
|
|
* @param string $username Nom d'utilisateur tenté
|
|
* @param string $reason Raison (invalid_password, user_not_found, account_inactive, blocked_ip)
|
|
* @param int $attempt Numéro de tentative
|
|
*/
|
|
public static function logLoginFailed(string $username, string $reason, int $attempt = 1): void
|
|
{
|
|
self::writeEvent('login_failed', [
|
|
'username' => $username,
|
|
'reason' => $reason,
|
|
'attempt' => $attempt
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log une déconnexion
|
|
*
|
|
* @param int $userId ID utilisateur
|
|
* @param int|null $entityId ID entité
|
|
* @param int $sessionDuration Durée session en secondes
|
|
*/
|
|
public static function logLogout(int $userId, ?int $entityId, int $sessionDuration = 0): void
|
|
{
|
|
self::writeEvent('logout', [
|
|
'user_id' => $userId,
|
|
'entity_id' => $entityId,
|
|
'session_duration' => $sessionDuration
|
|
]);
|
|
}
|
|
|
|
// ==================== MÉTHODES PASSAGES ====================
|
|
|
|
/**
|
|
* Log la création d'un passage
|
|
*
|
|
* @param int $passageId ID du passage créé
|
|
* @param int $operationId ID opération
|
|
* @param int $sectorId ID secteur
|
|
* @param float $amount Montant
|
|
* @param string $paymentType Type paiement (cash, stripe, check, etc.)
|
|
*/
|
|
public static function logPassageCreated(
|
|
int $passageId,
|
|
int $operationId,
|
|
int $sectorId,
|
|
float $amount,
|
|
string $paymentType
|
|
): void {
|
|
self::writeEvent('passage_created', [
|
|
'passage_id' => $passageId,
|
|
'operation_id' => $operationId,
|
|
'sector_id' => $sectorId,
|
|
'amount' => $amount,
|
|
'payment_type' => $paymentType
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log la modification d'un passage
|
|
*
|
|
* @param int $passageId ID du passage
|
|
* @param array $changes Tableau des changements ['field' => ['old' => val, 'new' => val]]
|
|
*/
|
|
public static function logPassageUpdated(int $passageId, array $changes): void
|
|
{
|
|
self::writeEvent('passage_updated', [
|
|
'passage_id' => $passageId,
|
|
'changes' => $changes
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log la suppression d'un passage
|
|
*
|
|
* @param int $passageId ID du passage
|
|
* @param int $operationId ID opération
|
|
* @param bool $softDelete Suppression logique ou physique
|
|
*/
|
|
public static function logPassageDeleted(int $passageId, int $operationId, bool $softDelete = true): void
|
|
{
|
|
$userId = Session::getUserId();
|
|
self::writeEvent('passage_deleted', [
|
|
'passage_id' => $passageId,
|
|
'operation_id' => $operationId,
|
|
'deleted_by' => $userId,
|
|
'soft_delete' => $softDelete
|
|
]);
|
|
}
|
|
|
|
// ==================== MÉTHODES SECTEURS ====================
|
|
|
|
/**
|
|
* Log la création d'un secteur
|
|
*
|
|
* @param int $sectorId ID du secteur créé
|
|
* @param int $operationId ID opération
|
|
* @param string $sectorName Nom du secteur
|
|
*/
|
|
public static function logSectorCreated(int $sectorId, int $operationId, string $sectorName): void
|
|
{
|
|
self::writeEvent('sector_created', [
|
|
'sector_id' => $sectorId,
|
|
'operation_id' => $operationId,
|
|
'sector_name' => $sectorName
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log la modification d'un secteur
|
|
*
|
|
* @param int $sectorId ID du secteur
|
|
* @param int $operationId ID opération
|
|
* @param array $changes Tableau des changements
|
|
*/
|
|
public static function logSectorUpdated(int $sectorId, int $operationId, array $changes): void
|
|
{
|
|
self::writeEvent('sector_updated', [
|
|
'sector_id' => $sectorId,
|
|
'operation_id' => $operationId,
|
|
'changes' => $changes
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log la suppression d'un secteur
|
|
*
|
|
* @param int $sectorId ID du secteur
|
|
* @param int $operationId ID opération
|
|
* @param bool $softDelete Suppression logique ou physique
|
|
*/
|
|
public static function logSectorDeleted(int $sectorId, int $operationId, bool $softDelete = true): void
|
|
{
|
|
$userId = Session::getUserId();
|
|
self::writeEvent('sector_deleted', [
|
|
'sector_id' => $sectorId,
|
|
'operation_id' => $operationId,
|
|
'deleted_by' => $userId,
|
|
'soft_delete' => $softDelete
|
|
]);
|
|
}
|
|
|
|
// ==================== MÉTHODES USERS ====================
|
|
|
|
/**
|
|
* Log la création d'un utilisateur
|
|
*
|
|
* @param int $newUserId ID utilisateur créé
|
|
* @param int $entityId ID entité
|
|
* @param int $roleId ID rôle
|
|
* @param string $username Nom d'utilisateur
|
|
*/
|
|
public static function logUserCreated(int $newUserId, int $entityId, int $roleId, string $username): void
|
|
{
|
|
$createdBy = Session::getUserId();
|
|
self::writeEvent('user_created', [
|
|
'new_user_id' => $newUserId,
|
|
'entity_id' => $entityId,
|
|
'created_by' => $createdBy,
|
|
'role_id' => $roleId,
|
|
'username' => $username
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log la modification d'un utilisateur
|
|
*
|
|
* @param int $userId ID utilisateur modifié
|
|
* @param array $changes Tableau des changements (booléen pour champs chiffrés)
|
|
*/
|
|
public static function logUserUpdated(int $userId, array $changes): void
|
|
{
|
|
$updatedBy = Session::getUserId();
|
|
$entityId = Session::getEntityId();
|
|
|
|
self::writeEvent('user_updated', [
|
|
'user_id' => $userId,
|
|
'entity_id' => $entityId,
|
|
'updated_by' => $updatedBy,
|
|
'changes' => $changes
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log la suppression d'un utilisateur
|
|
*
|
|
* @param int $userId ID utilisateur supprimé
|
|
* @param bool $softDelete Suppression logique ou physique
|
|
*/
|
|
public static function logUserDeleted(int $userId, bool $softDelete = true): void
|
|
{
|
|
$deletedBy = Session::getUserId();
|
|
$entityId = Session::getEntityId();
|
|
|
|
self::writeEvent('user_deleted', [
|
|
'user_id' => $userId,
|
|
'entity_id' => $entityId,
|
|
'deleted_by' => $deletedBy,
|
|
'soft_delete' => $softDelete
|
|
]);
|
|
}
|
|
|
|
// ==================== MÉTHODES ENTITÉS ====================
|
|
|
|
/**
|
|
* Log la création d'une entité
|
|
*
|
|
* @param int $entityId ID entité créée
|
|
* @param int $entityTypeId Type d'entité
|
|
* @param string $postalCode Code postal
|
|
*/
|
|
public static function logEntityCreated(int $entityId, int $entityTypeId, string $postalCode): void
|
|
{
|
|
$createdBy = Session::getUserId() ?? 1; // Super-admin par défaut
|
|
self::writeEvent('entity_created', [
|
|
'entity_id' => $entityId,
|
|
'created_by' => $createdBy,
|
|
'entity_type_id' => $entityTypeId,
|
|
'postal_code' => $postalCode
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log la modification d'une entité
|
|
*
|
|
* @param int $entityId ID entité
|
|
* @param array $changes Tableau des changements (booléen pour champs chiffrés)
|
|
*/
|
|
public static function logEntityUpdated(int $entityId, array $changes): void
|
|
{
|
|
$updatedBy = Session::getUserId();
|
|
self::writeEvent('entity_updated', [
|
|
'entity_id' => $entityId,
|
|
'user_id' => $updatedBy,
|
|
'updated_by' => $updatedBy,
|
|
'changes' => $changes
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log la suppression d'une entité
|
|
*
|
|
* @param int $entityId ID entité
|
|
* @param string $reason Raison de la suppression
|
|
*/
|
|
public static function logEntityDeleted(int $entityId, string $reason = ''): void
|
|
{
|
|
$deletedBy = Session::getUserId() ?? 1;
|
|
self::writeEvent('entity_deleted', [
|
|
'entity_id' => $entityId,
|
|
'deleted_by' => $deletedBy,
|
|
'soft_delete' => true,
|
|
'reason' => $reason
|
|
]);
|
|
}
|
|
|
|
// ==================== MÉTHODES STRIPE ====================
|
|
|
|
/**
|
|
* Log la création d'un PaymentIntent
|
|
*
|
|
* @param string $paymentIntentId ID Stripe du PaymentIntent
|
|
* @param int $passageId ID du passage
|
|
* @param int $amount Montant en centimes
|
|
* @param string $method Méthode (tap_to_pay, qr_code, web)
|
|
*/
|
|
public static function logStripePaymentCreated(
|
|
string $paymentIntentId,
|
|
int $passageId,
|
|
int $amount,
|
|
string $method
|
|
): void {
|
|
$entityId = Session::getEntityId();
|
|
self::writeEvent('stripe_payment_created', [
|
|
'payment_intent_id' => $paymentIntentId,
|
|
'passage_id' => $passageId,
|
|
'entity_id' => $entityId,
|
|
'amount' => $amount,
|
|
'method' => $method
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log un paiement Stripe réussi
|
|
*
|
|
* @param string $paymentIntentId ID Stripe du PaymentIntent
|
|
* @param int $passageId ID du passage
|
|
* @param int $amount Montant en centimes
|
|
* @param string $method Méthode (tap_to_pay, qr_code, web)
|
|
*/
|
|
public static function logStripePaymentSuccess(
|
|
string $paymentIntentId,
|
|
int $passageId,
|
|
int $amount,
|
|
string $method
|
|
): void {
|
|
$entityId = Session::getEntityId();
|
|
self::writeEvent('stripe_payment_success', [
|
|
'payment_intent_id' => $paymentIntentId,
|
|
'passage_id' => $passageId,
|
|
'entity_id' => $entityId,
|
|
'amount' => $amount,
|
|
'method' => $method
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log un paiement Stripe échoué
|
|
*
|
|
* @param string|null $paymentIntentId ID Stripe (peut être null si création échouée)
|
|
* @param int|null $passageId ID du passage (peut être null)
|
|
* @param int|null $amount Montant en centimes (peut être null)
|
|
* @param string $method Méthode tentée
|
|
* @param string $errorCode Code d'erreur
|
|
* @param string $errorMessage Message d'erreur
|
|
*/
|
|
public static function logStripePaymentFailed(
|
|
?string $paymentIntentId,
|
|
?int $passageId,
|
|
?int $amount,
|
|
string $method,
|
|
string $errorCode,
|
|
string $errorMessage
|
|
): void {
|
|
$entityId = Session::getEntityId();
|
|
$data = [
|
|
'entity_id' => $entityId,
|
|
'method' => $method,
|
|
'error_code' => $errorCode,
|
|
'error_message' => $errorMessage
|
|
];
|
|
|
|
if ($paymentIntentId !== null) {
|
|
$data['payment_intent_id'] = $paymentIntentId;
|
|
}
|
|
if ($passageId !== null) {
|
|
$data['passage_id'] = $passageId;
|
|
}
|
|
if ($amount !== null) {
|
|
$data['amount'] = $amount;
|
|
}
|
|
|
|
self::writeEvent('stripe_payment_failed', $data);
|
|
}
|
|
|
|
/**
|
|
* Log l'annulation d'un paiement Stripe
|
|
*
|
|
* @param string $paymentIntentId ID Stripe du PaymentIntent
|
|
* @param int|null $passageId ID du passage (peut être null)
|
|
* @param string $reason Raison (user_cancelled, timeout, error, etc.)
|
|
*/
|
|
public static function logStripePaymentCancelled(
|
|
string $paymentIntentId,
|
|
?int $passageId,
|
|
string $reason
|
|
): void {
|
|
$entityId = Session::getEntityId();
|
|
$data = [
|
|
'payment_intent_id' => $paymentIntentId,
|
|
'entity_id' => $entityId,
|
|
'reason' => $reason
|
|
];
|
|
|
|
if ($passageId !== null) {
|
|
$data['passage_id'] = $passageId;
|
|
}
|
|
|
|
self::writeEvent('stripe_payment_cancelled', $data);
|
|
}
|
|
|
|
/**
|
|
* Log une erreur du Terminal Tap to Pay
|
|
*
|
|
* @param string $errorCode Code d'erreur (cardReadTimedOut, device_not_compatible, etc.)
|
|
* @param string $errorMessage Message d'erreur
|
|
* @param array $metadata Métadonnées supplémentaires (device_model, is_simulated, etc.)
|
|
*/
|
|
public static function logStripeTerminalError(
|
|
string $errorCode,
|
|
string $errorMessage,
|
|
array $metadata = []
|
|
): void {
|
|
$entityId = Session::getEntityId();
|
|
self::writeEvent('stripe_terminal_error', array_merge([
|
|
'entity_id' => $entityId,
|
|
'error_code' => $errorCode,
|
|
'error_message' => $errorMessage
|
|
], $metadata));
|
|
}
|
|
|
|
// ==================== MÉTHODES OPÉRATIONS ====================
|
|
|
|
/**
|
|
* Log la création d'une opération
|
|
*
|
|
* @param int $operationId ID opération créée
|
|
* @param string $dateStart Date début (YYYY-MM-DD)
|
|
* @param string $dateEnd Date fin (YYYY-MM-DD)
|
|
*/
|
|
public static function logOperationCreated(int $operationId, string $dateStart, string $dateEnd): void
|
|
{
|
|
$entityId = Session::getEntityId();
|
|
$createdBy = Session::getUserId();
|
|
|
|
self::writeEvent('operation_created', [
|
|
'operation_id' => $operationId,
|
|
'entity_id' => $entityId,
|
|
'created_by' => $createdBy,
|
|
'date_start' => $dateStart,
|
|
'date_end' => $dateEnd
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log la modification d'une opération
|
|
*
|
|
* @param int $operationId ID opération
|
|
* @param array $changes Tableau des changements
|
|
*/
|
|
public static function logOperationUpdated(int $operationId, array $changes): void
|
|
{
|
|
$entityId = Session::getEntityId();
|
|
$updatedBy = Session::getUserId();
|
|
|
|
self::writeEvent('operation_updated', [
|
|
'operation_id' => $operationId,
|
|
'entity_id' => $entityId,
|
|
'updated_by' => $updatedBy,
|
|
'changes' => $changes
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Log la suppression d'une opération
|
|
*
|
|
* @param int $operationId ID opération
|
|
* @param bool $softDelete Suppression logique ou physique
|
|
*/
|
|
public static function logOperationDeleted(int $operationId, bool $softDelete = true): void
|
|
{
|
|
$entityId = Session::getEntityId();
|
|
$deletedBy = Session::getUserId();
|
|
|
|
self::writeEvent('operation_deleted', [
|
|
'operation_id' => $operationId,
|
|
'entity_id' => $entityId,
|
|
'deleted_by' => $deletedBy,
|
|
'soft_delete' => $softDelete
|
|
]);
|
|
}
|
|
|
|
// ==================== MÉTHODES PRIVÉES ====================
|
|
|
|
/**
|
|
* Méthode centrale d'écriture d'un événement
|
|
*
|
|
* @param string $eventName Nom de l'événement
|
|
* @param array $data Données spécifiques à l'événement
|
|
*/
|
|
private static function writeEvent(string $eventName, array $data): void
|
|
{
|
|
try {
|
|
// Enrichir avec timestamp, user_id, entity_id, IP, platform, app_version
|
|
$event = self::enrichEvent($eventName, $data);
|
|
|
|
// Générer le chemin du fichier quotidien
|
|
$filename = self::LOG_DIR . '/' . date('Y-m-d') . '.jsonl';
|
|
|
|
// Créer le dossier si nécessaire
|
|
self::ensureLogDirectoryExists();
|
|
|
|
// Encoder en JSON compact (une ligne)
|
|
$jsonLine = json_encode($event, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
|
|
|
|
// Écrire en mode append
|
|
if (file_put_contents($filename, $jsonLine, FILE_APPEND | LOCK_EX) === false) {
|
|
error_log("EventLogService: Impossible d'écrire dans {$filename}");
|
|
return;
|
|
}
|
|
|
|
// Appliquer les permissions au fichier
|
|
if (file_exists($filename)) {
|
|
@chmod($filename, self::FILE_PERMISSIONS);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Ne jamais bloquer l'application si le logging échoue
|
|
error_log("EventLogService: Erreur lors de l'écriture de l'événement {$eventName}: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enrichit un événement avec les métadonnées communes
|
|
*
|
|
* @param string $eventName Nom de l'événement
|
|
* @param array $data Données de l'événement
|
|
* @return array Événement enrichi
|
|
*/
|
|
private static function enrichEvent(string $eventName, array $data): array
|
|
{
|
|
// Récupérer les informations client
|
|
$clientInfo = ClientDetector::getClientInfo();
|
|
|
|
// Structure de base
|
|
$event = [
|
|
'timestamp' => gmdate('Y-m-d\TH:i:s\Z'), // ISO 8601 UTC
|
|
'event' => $eventName,
|
|
];
|
|
|
|
// Ajouter user_id si disponible et pas déjà dans $data
|
|
if (!isset($data['user_id'])) {
|
|
$userId = Session::getUserId();
|
|
if ($userId !== null) {
|
|
$event['user_id'] = $userId;
|
|
}
|
|
}
|
|
|
|
// Ajouter entity_id si disponible et pas déjà dans $data
|
|
if (!isset($data['entity_id'])) {
|
|
$entityId = Session::getEntityId();
|
|
if ($entityId !== null) {
|
|
$event['entity_id'] = $entityId;
|
|
}
|
|
}
|
|
|
|
// Fusionner avec les données spécifiques
|
|
$event = array_merge($event, $data);
|
|
|
|
// Ajouter IP
|
|
$event['ip'] = $clientInfo['ip'];
|
|
|
|
// Ajouter platform
|
|
$event['platform'] = self::getPlatform($clientInfo);
|
|
|
|
// Ajouter app_version si mobile
|
|
if ($event['platform'] === 'ios' || $event['platform'] === 'android') {
|
|
$appVersion = self::getAppVersion($clientInfo);
|
|
if ($appVersion !== null) {
|
|
$event['app_version'] = $appVersion;
|
|
}
|
|
}
|
|
|
|
return $event;
|
|
}
|
|
|
|
/**
|
|
* Détermine la plateforme (ios, android, web)
|
|
*
|
|
* @param array $clientInfo Informations client de ClientDetector
|
|
* @return string Platform (ios|android|web)
|
|
*/
|
|
private static function getPlatform(array $clientInfo): string
|
|
{
|
|
if ($clientInfo['type'] !== 'mobile') {
|
|
return 'web';
|
|
}
|
|
|
|
$userAgent = $clientInfo['userAgent'];
|
|
|
|
// Détection iOS
|
|
if (stripos($userAgent, 'iOS') !== false ||
|
|
stripos($userAgent, 'iPhone') !== false ||
|
|
stripos($userAgent, 'iPad') !== false) {
|
|
return 'ios';
|
|
}
|
|
|
|
// Détection Android
|
|
if (stripos($userAgent, 'Android') !== false) {
|
|
return 'android';
|
|
}
|
|
|
|
// Par défaut mobile générique = web
|
|
return 'web';
|
|
}
|
|
|
|
/**
|
|
* Extrait la version de l'application depuis le User-Agent
|
|
* Format attendu: AppName/VersionNumber ou Platform/Version AppName/Version
|
|
*
|
|
* @param array $clientInfo Informations client
|
|
* @return string|null Version de l'app ou null
|
|
*/
|
|
private static function getAppVersion(array $clientInfo): ?string
|
|
{
|
|
$userAgent = $clientInfo['userAgent'];
|
|
|
|
// Tentative extraction format: GeoSector/3.3.6
|
|
if (preg_match('/GeoSector\/([0-9\.]+)/', $userAgent, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
|
|
// Format alternatif: AppName/Version
|
|
if (preg_match('/([A-Za-z0-9_]+)\/([0-9\.]+)/', $userAgent, $matches)) {
|
|
return $matches[2];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Crée le dossier de logs si nécessaire avec les bonnes permissions
|
|
*/
|
|
private static function ensureLogDirectoryExists(): void
|
|
{
|
|
if (!is_dir(self::LOG_DIR)) {
|
|
if (!@mkdir(self::LOG_DIR, self::DIR_PERMISSIONS, true)) {
|
|
error_log("EventLogService: Impossible de créer le dossier " . self::LOG_DIR);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Vérifier les permissions
|
|
if (!is_writable(self::LOG_DIR)) {
|
|
@chmod(self::LOG_DIR, self::DIR_PERMISSIONS);
|
|
}
|
|
}
|
|
}
|