feat: Version 3.5.2 - Configuration Stripe et gestion des immeubles

- Configuration complète Stripe pour les 3 environnements (DEV/REC/PROD)
  * DEV: Clés TEST Pierre (mode test)
  * REC: Clés TEST Client (mode test)
  * PROD: Clés LIVE Client (mode live)
- Ajout de la gestion des bases de données immeubles/bâtiments
  * Configuration buildings_database pour DEV/REC/PROD
  * Service BuildingService pour enrichissement des adresses
- Optimisations pages et améliorations ergonomie
- Mises à jour des dépendances Composer
- Nettoyage des fichiers obsolètes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pierre
2025-11-09 18:26:27 +01:00
parent 21657a3820
commit 2f5946a184
812 changed files with 142105 additions and 25992 deletions

View File

@@ -7,13 +7,19 @@ namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
// Les classes sont déjà incluses via require_once, pas besoin de 'use' statements
use PDO;
use Database;
use Request;
use Response;
use Session;
use App\Services\LogService;
use App\Services\ApiService;
class ChatController {
private \PDO $db;
private PDO $db;
public function __construct() {
$this->db = \Database::getInstance();
$this->db = Database::getInstance();
}
/**
@@ -24,8 +30,8 @@ class ChatController {
// L'authentification est déjà vérifiée par le Router pour les routes privées
try {
$userId = \Session::getUserId();
$entityId = \Session::getEntityId();
$userId = Session::getUserId();
$entityId = Session::getEntityId();
// Vérifier si c'est une synchronisation incrémentale
$updatedAfter = $_GET['updated_after'] ?? null;
@@ -186,7 +192,7 @@ class ChatController {
}
}
\Response::json([
Response::json([
'status' => 'success',
'sync_timestamp' => $syncTimestamp,
'has_changes' => !empty($rooms),
@@ -194,11 +200,11 @@ class ChatController {
]);
} catch (\PDOException $e) {
\LogService::log('Erreur lors de la récupération des conversations', [
LogService::log('Erreur lors de la récupération des conversations', [
'level' => 'error',
'error' => $e->getMessage()
]);
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
@@ -213,9 +219,9 @@ class ChatController {
// L'authentification est déjà vérifiée par le Router pour les routes privées
try {
$data = \Request::getJson();
$userId = \Session::getUserId();
$entityId = \Session::getEntityId();
$data = Request::getJson();
$userId = Session::getUserId();
$entityId = Session::getEntityId();
$userRole = $this->getUserRole($userId);
// Récupérer le temp_id s'il est fourni (pour la synchronisation offline)
@@ -223,7 +229,7 @@ class ChatController {
// Validation des données
if (!isset($data['type']) || !in_array($data['type'], ['private', 'group', 'broadcast'])) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Type de conversation invalide'
], 400);
@@ -233,7 +239,7 @@ class ChatController {
// Vérification des permissions pour broadcast
// Seuls les super admins (role = 9) peuvent créer des broadcasts
if ($data['type'] === 'broadcast' && $userRole != 9) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Seuls les super administrateurs peuvent créer des annonces'
], 403);
@@ -242,7 +248,7 @@ class ChatController {
// Validation des participants
if (!isset($data['participants']) || !is_array($data['participants']) || empty($data['participants'])) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Au moins un participant requis'
], 400);
@@ -251,7 +257,7 @@ class ChatController {
// Pour une conversation privée, limiter à 2 participants (incluant le créateur)
if ($data['type'] === 'private' && count($data['participants']) > 1) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Une conversation privée ne peut avoir que 2 participants'
], 400);
@@ -272,7 +278,7 @@ class ChatController {
if ($tempId !== null) {
$existingRoom['temp_id'] = $tempId;
}
\Response::json([
Response::json([
'status' => 'success',
'room' => $existingRoom,
'existing' => true
@@ -351,7 +357,7 @@ class ChatController {
$this->db->commit();
\LogService::log('Conversation créée', [
LogService::log('Conversation créée', [
'level' => 'info',
'room_id' => $roomId,
'type' => $data['type'],
@@ -367,7 +373,7 @@ class ChatController {
$room['temp_id'] = $tempId;
}
\Response::json([
Response::json([
'status' => 'success',
'room' => $room
], 201);
@@ -378,20 +384,20 @@ class ChatController {
}
} catch (\PDOException $e) {
\LogService::log('Erreur lors de la création de la conversation', [
LogService::log('Erreur lors de la création de la conversation', [
'level' => 'error',
'error' => $e->getMessage()
]);
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
} catch (\Exception $e) {
\LogService::log('Erreur lors de la création de la conversation', [
LogService::log('Erreur lors de la création de la conversation', [
'level' => 'error',
'error' => $e->getMessage()
]);
\Response::json([
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 400);
@@ -406,8 +412,8 @@ class ChatController {
// L'authentification est déjà vérifiée par le Router pour les routes privées
try {
$data = \Request::getJson();
$userId = \Session::getUserId();
$data = Request::getJson();
$userId = Session::getUserId();
// Récupérer le temp_id s'il est fourni (pour la synchronisation offline)
$tempId = $data['temp_id'] ?? null;
@@ -423,7 +429,7 @@ class ChatController {
$room = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$room) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Conversation non trouvée ou accès non autorisé'
], 404);
@@ -432,7 +438,7 @@ class ChatController {
// Vérifier les permissions
if ($room['created_by'] != $userId && !$room['is_admin']) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Seul le créateur ou un admin peut modifier la conversation'
], 403);
@@ -460,24 +466,24 @@ class ChatController {
$updatedRoom['temp_id'] = $tempId;
}
\LogService::log('Conversation mise à jour', [
LogService::log('Conversation mise à jour', [
'level' => 'info',
'room_id' => $roomId,
'updated_by' => $userId
]);
\Response::json([
Response::json([
'status' => 'success',
'room' => $updatedRoom
]);
} catch (\PDOException $e) {
\LogService::log('Erreur lors de la mise à jour de la conversation', [
LogService::log('Erreur lors de la mise à jour de la conversation', [
'level' => 'error',
'room_id' => $roomId,
'error' => $e->getMessage()
]);
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
@@ -493,7 +499,7 @@ class ChatController {
// L'authentification est déjà vérifiée par le Router pour les routes privées
try {
$userId = \Session::getUserId();
$userId = Session::getUserId();
// Vérifier que la room existe et récupérer le créateur
$stmt = $this->db->prepare('
@@ -506,7 +512,7 @@ class ChatController {
// Vérifier que la room existe
if (!$room) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Conversation non trouvée'
], 404);
@@ -515,7 +521,7 @@ class ChatController {
// Vérifier que la room n'est pas déjà supprimée
if ($room['is_active'] == 0) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Cette conversation est déjà supprimée'
], 400);
@@ -524,7 +530,7 @@ class ChatController {
// Vérifier que l'utilisateur est le créateur
if ($room['created_by'] != $userId) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Seul le créateur de la conversation peut la supprimer'
], 403);
@@ -554,13 +560,13 @@ class ChatController {
$this->db->commit();
\LogService::log('Conversation supprimée', [
LogService::log('Conversation supprimée', [
'level' => 'info',
'room_id' => $roomId,
'deleted_by' => $userId
]);
\Response::json([
Response::json([
'status' => 'success',
'message' => 'Conversation supprimée avec succès'
]);
@@ -573,12 +579,12 @@ class ChatController {
}
} catch (\PDOException $e) {
\LogService::log('Erreur lors de la suppression de la conversation', [
LogService::log('Erreur lors de la suppression de la conversation', [
'level' => 'error',
'room_id' => $roomId,
'error' => $e->getMessage()
]);
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
@@ -593,11 +599,11 @@ class ChatController {
// L'authentification est déjà vérifiée par le Router pour les routes privées
try {
$userId = \Session::getUserId();
$userId = Session::getUserId();
// Vérifier que l'utilisateur est participant
if (!$this->isUserInRoom($userId, $roomId)) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Accès non autorisé à cette conversation'
], 403);
@@ -658,7 +664,7 @@ class ChatController {
// Déchiffrer les noms
foreach ($messages as &$message) {
$message['sender_name'] = \ApiService::decryptData($message['sender_name']);
$message['sender_name'] = ApiService::decryptData($message['sender_name']);
$message['is_read'] = (bool)$message['is_read'];
$message['is_mine'] = ($message['sender_id'] == $userId);
}
@@ -675,7 +681,7 @@ class ChatController {
// Compter les messages non lus restants (devrait être 0)
$unreadCount = $this->getUnreadCount($roomId, $userId);
\Response::json([
Response::json([
'status' => 'success',
'messages' => $messages,
'has_more' => count($messages) === $limit,
@@ -684,12 +690,12 @@ class ChatController {
]);
} catch (\PDOException $e) {
\LogService::log('Erreur lors de la récupération des messages', [
LogService::log('Erreur lors de la récupération des messages', [
'level' => 'error',
'room_id' => $roomId,
'error' => $e->getMessage()
]);
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
@@ -704,15 +710,15 @@ class ChatController {
// L'authentification est déjà vérifiée par le Router pour les routes privées
try {
$data = \Request::getJson();
$userId = \Session::getUserId();
$data = Request::getJson();
$userId = Session::getUserId();
// Récupérer le temp_id s'il est fourni (pour la synchronisation offline)
$tempId = $data['temp_id'] ?? null;
// Vérifier que l'utilisateur est participant
if (!$this->isUserInRoom($userId, $roomId)) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Accès non autorisé à cette conversation'
], 403);
@@ -724,7 +730,7 @@ class ChatController {
if ($roomInfo && $roomInfo['type'] === 'broadcast') {
// Pour les broadcasts, seul le créateur peut écrire
if ($roomInfo['created_by'] != $userId) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Seul l\'administrateur peut poster dans une annonce'
], 403);
@@ -733,7 +739,7 @@ class ChatController {
} else {
// Pour les autres types, vérifier can_write
if (!$this->canUserWrite($userId, $roomId)) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Vous n\'avez pas la permission d\'écrire dans cette conversation'
], 403);
@@ -743,7 +749,7 @@ class ChatController {
// Validation du contenu
if (!isset($data['content']) || empty(trim($data['content']))) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Le message ne peut pas être vide'
], 400);
@@ -754,7 +760,7 @@ class ChatController {
// Limiter la longueur du message
if (mb_strlen($content, 'UTF-8') > 5000) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Message trop long (max 5000 caractères)'
], 400);
@@ -799,7 +805,7 @@ class ChatController {
$msgStmt->execute(['id' => $messageId]);
$message = $msgStmt->fetch(\PDO::FETCH_ASSOC);
$message['sender_name'] = \ApiService::decryptData($message['sender_name']);
$message['sender_name'] = ApiService::decryptData($message['sender_name']);
$message['is_mine'] = true;
$message['is_read'] = false;
$message['read_count'] = 0;
@@ -809,25 +815,25 @@ class ChatController {
$message['temp_id'] = $tempId;
}
\LogService::log('Message envoyé', [
LogService::log('Message envoyé', [
'level' => 'debug',
'room_id' => $roomId,
'message_id' => $messageId,
'sender_id' => $userId
]);
\Response::json([
Response::json([
'status' => 'success',
'message' => $message
], 201);
} catch (\PDOException $e) {
\LogService::log('Erreur lors de l\'envoi du message', [
LogService::log('Erreur lors de l\'envoi du message', [
'level' => 'error',
'room_id' => $roomId,
'error' => $e->getMessage()
]);
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
@@ -842,8 +848,8 @@ class ChatController {
// L'authentification est déjà vérifiée par le Router pour les routes privées
try {
$data = \Request::getJson();
$userId = \Session::getUserId();
$data = Request::getJson();
$userId = Session::getUserId();
// Récupérer le temp_id s'il est fourni (pour la synchronisation offline)
$tempId = $data['temp_id'] ?? null;
@@ -858,7 +864,7 @@ class ChatController {
$message = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$message) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Message non trouvé'
], 404);
@@ -867,7 +873,7 @@ class ChatController {
// Vérifier que l'utilisateur est le sender du message
if ($message['sender_id'] != $userId) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Vous ne pouvez modifier que vos propres messages'
], 403);
@@ -876,7 +882,7 @@ class ChatController {
// Vérifier que le message n'est pas supprimé
if ($message['is_deleted']) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Ce message a été supprimé'
], 400);
@@ -885,7 +891,7 @@ class ChatController {
// Validation du contenu
if (!isset($data['content']) || empty(trim($data['content']))) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Le message ne peut pas être vide'
], 400);
@@ -896,7 +902,7 @@ class ChatController {
// Limiter la longueur du message
if (mb_strlen($content, 'UTF-8') > 5000) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Message trop long (max 5000 caractères)'
], 400);
@@ -931,7 +937,7 @@ class ChatController {
$msgStmt->execute(['id' => $messageId]);
$updatedMessage = $msgStmt->fetch(\PDO::FETCH_ASSOC);
$updatedMessage['sender_name'] = \ApiService::decryptData($updatedMessage['sender_name']);
$updatedMessage['sender_name'] = ApiService::decryptData($updatedMessage['sender_name']);
$updatedMessage['is_mine'] = true;
// Ajouter le temp_id à la réponse si fourni
@@ -939,24 +945,24 @@ class ChatController {
$updatedMessage['temp_id'] = $tempId;
}
\LogService::log('Message modifié', [
LogService::log('Message modifié', [
'level' => 'debug',
'message_id' => $messageId,
'sender_id' => $userId
]);
\Response::json([
Response::json([
'status' => 'success',
'message' => $updatedMessage
]);
} catch (\PDOException $e) {
\LogService::log('Erreur lors de la modification du message', [
LogService::log('Erreur lors de la modification du message', [
'level' => 'error',
'message_id' => $messageId,
'error' => $e->getMessage()
]);
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
@@ -971,12 +977,12 @@ class ChatController {
// L'authentification est déjà vérifiée par le Router pour les routes privées
try {
$data = \Request::getJson();
$userId = \Session::getUserId();
$data = Request::getJson();
$userId = Session::getUserId();
// Vérifier que l'utilisateur est participant
if (!$this->isUserInRoom($userId, $roomId)) {
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Accès non autorisé à cette conversation'
], 403);
@@ -1041,18 +1047,18 @@ class ChatController {
]);
$result = $countStmt->fetch(\PDO::FETCH_ASSOC);
\Response::json([
Response::json([
'status' => 'success',
'unread_count' => (int)$result['unread_count']
]);
} catch (\PDOException $e) {
\LogService::log('Erreur lors du marquage comme lu', [
LogService::log('Erreur lors du marquage comme lu', [
'level' => 'error',
'room_id' => $roomId,
'error' => $e->getMessage()
]);
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
@@ -1067,8 +1073,8 @@ class ChatController {
// L'authentification est déjà vérifiée par le Router pour les routes privées
try {
$userId = \Session::getUserId();
$entityId = \Session::getEntityId();
$userId = Session::getUserId();
$entityId = Session::getEntityId();
$userRole = $this->getUserRole($userId);
$sql = '
@@ -1122,11 +1128,11 @@ class ChatController {
foreach ($recipients as &$recipient) {
// Déchiffrer le nom
$recipient['name'] = \ApiService::decryptData($recipient['name']);
$recipient['name'] = ApiService::decryptData($recipient['name']);
// Déchiffrer le nom de l'entité
$entiteName = $recipient['entite_name'] ?
\ApiService::decryptData($recipient['entite_name']) :
ApiService::decryptData($recipient['entite_name']) :
'Sans entité';
// Créer une copie pour recipients_by_entity
@@ -1146,18 +1152,18 @@ class ChatController {
$recipientsDecrypted[] = $recipient;
}
\Response::json([
Response::json([
'status' => 'success',
'recipients' => $recipientsDecrypted,
'recipients_by_entity' => $recipientsByEntity
]);
} catch (\PDOException $e) {
\LogService::log('Erreur lors de la récupération des destinataires', [
LogService::log('Erreur lors de la récupération des destinataires', [
'level' => 'error',
'error' => $e->getMessage()
]);
\Response::json([
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
@@ -1225,7 +1231,7 @@ class ChatController {
$participants = $stmt->fetchAll(\PDO::FETCH_ASSOC);
foreach ($participants as &$participant) {
$participant['name'] = \ApiService::decryptData($participant['name']);
$participant['name'] = ApiService::decryptData($participant['name']);
}
return $participants;
@@ -1349,7 +1355,7 @@ class ChatController {
// Déchiffrer les noms et convertir les booléens
foreach ($messages as &$message) {
$message['sender_name'] = \ApiService::decryptData($message['sender_name']);
$message['sender_name'] = ApiService::decryptData($message['sender_name']);
$message['is_read'] = (bool)$message['is_read'];
$message['is_mine'] = (bool)$message['is_mine'];
}
@@ -1398,7 +1404,7 @@ class ChatController {
// Déchiffrer les noms et convertir les booléens
foreach ($messages as &$message) {
$message['sender_name'] = \ApiService::decryptData($message['sender_name']);
$message['sender_name'] = ApiService::decryptData($message['sender_name']);
$message['is_read'] = (bool)$message['is_read'];
$message['is_mine'] = (bool)$message['is_mine'];
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/EventLogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
use PDO;
@@ -14,8 +15,10 @@ use AppConfig;
use Request;
use Response;
use Session;
use LogService;
use ApiService;
use App\Services\LogService;
use App\Services\EventLogService;
use App\Services\ApiService;
use App\Services\FileService;
use Exception;
class EntiteController {
@@ -74,13 +77,12 @@ class EntiteController {
throw new Exception('Erreur lors de la création de l\'entité');
}
LogService::log('Création d\'une nouvelle entité GeoSector', [
'level' => 'info',
'entiteId' => $entiteId,
'name' => $name,
'postalCode' => $postalCode,
'cityName' => $cityName
]);
// Log de création de l'entité
EventLogService::logEntityCreated(
(int)$entiteId,
1, // fk_type toujours à 1 dans cette méthode
$postalCode
);
return [
'id' => $entiteId,
@@ -220,12 +222,12 @@ class EntiteController {
throw new Exception('Erreur lors de la création de l\'entité');
}
LogService::log('Création d\'une nouvelle entité GeoSector via getOrCreateEntiteByPostalCode', [
'level' => 'info',
'entiteId' => $entiteId,
'name' => $name,
'postalCode' => $postalCode
]);
// Log de création de l'entité
EventLogService::logEntityCreated(
$entiteId,
1, // fk_type toujours à 1 dans cette méthode
$postalCode
);
return $entiteId;
} catch (Exception $e) {
@@ -559,10 +561,8 @@ class EntiteController {
$params[] = $data['gps_lng'];
}
if (isset($data['stripe_id'])) {
$updateFields[] = 'encrypted_stripe_id = ?';
$params[] = ApiService::encryptData($data['stripe_id']);
}
// Note: stripe_id ne peut plus être modifié ici
// Les données Stripe sont gérées via la table stripe_accounts
if (isset($data['chk_demo'])) {
$updateFields[] = 'chk_demo = ?';
@@ -629,12 +629,23 @@ class EntiteController {
return;
}
LogService::log('Mise à jour d\'une entité GeoSector', [
'level' => 'info',
'userId' => $userId,
'entiteId' => $entiteId,
'isAdmin' => $isAdmin
]);
// Log de mise à jour de l'entité
$changes = [];
$encryptedFields = ['name', 'email', 'phone', 'mobile'];
foreach ($data as $key => $value) {
if (in_array($key, $encryptedFields)) {
// Champs sensibles : booléen uniquement
$changes['encrypted_' . $key] = true;
} else {
// Champs non sensibles : valeur
$changes[$key] = ['new' => $value];
}
}
if (!empty($changes)) {
EventLogService::logEntityUpdated((int)$entiteId, $changes);
}
Response::json([
'status' => 'success',
@@ -738,7 +749,7 @@ class EntiteController {
// Créer le dossier de destination
require_once __DIR__ . '/../Services/FileService.php';
$fileService = new \FileService();
$fileService = new FileService();
$uploadPath = "/{$entiteId}/logo";
$fullPath = $fileService->createDirectory($entiteId, $uploadPath);

View File

@@ -14,8 +14,8 @@ use AppConfig;
use Request;
use Response;
use Session;
use LogService;
use ApiService;
use App\Services\LogService;
use App\Services\ApiService;
use Exception;
class FileController {

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Database;
use Response;
use PDO;
use Exception;
/**
* HealthController
*
* Endpoint de vérification de santé de l'API
* Route publique pour permettre le monitoring automatique
*/
class HealthController
{
private PDO $db;
public function __construct()
{
$this->db = Database::getInstance();
}
/**
* Vérifie la santé de l'API
* GET /api/health
*
* @return void
*/
public function check(): void
{
$checks = [
'api' => 'ok',
'database' => $this->checkDatabase(),
'directories' => $this->checkDirectories()
];
// Déterminer le statut global
$status = in_array('error', $checks, true) ? 'error' : 'ok';
$httpCode = $status === 'ok' ? 200 : 503;
Response::json([
'status' => $status,
'checks' => $checks,
'timestamp' => date('Y-m-d H:i:s'),
'environment' => $this->getEnvironment()
], $httpCode);
}
/**
* Vérifie la connexion à la base de données
*
* @return string 'ok' ou 'error'
*/
private function checkDatabase(): string
{
try {
$db = Database::getInstance();
$stmt = $db->query("SELECT 1");
return $stmt ? 'ok' : 'error';
} catch (Exception $e) {
error_log("Health check database error: " . $e->getMessage());
return 'error';
}
}
/**
* Vérifie l'accessibilité des dossiers critiques
*
* @return string 'ok' ou 'error'
*/
private function checkDirectories(): string
{
$basePath = __DIR__ . '/../../';
$requiredDirs = ['logs', 'uploads'];
foreach ($requiredDirs as $dir) {
$fullPath = $basePath . $dir;
if (!is_dir($fullPath)) {
error_log("Health check: Directory not found: $fullPath");
return 'error';
}
if (!is_writable($fullPath)) {
error_log("Health check: Directory not writable: $fullPath");
return 'error';
}
}
return 'ok';
}
/**
* Détecte l'environnement actuel
*
* @return string 'dev', 'recette' ou 'production'
*/
private function getEnvironment(): string
{
$host = $_SERVER['HTTP_HOST'] ?? 'unknown';
if (str_contains($host, 'dapp.geosector.fr')) {
return 'dev';
} elseif (str_contains($host, 'rapp.geosector.fr')) {
return 'recette';
} elseif (str_contains($host, 'app3.geosector.fr') || str_contains($host, 'app.geosector.fr')) {
return 'production';
}
return 'unknown';
}
}

View File

@@ -14,10 +14,12 @@ use AppConfig;
use Request;
use Response;
use Session;
use LogService;
use ApiService;
use App\Services\LogService;
use App\Services\ApiService;
use App\Services\EventLogService;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/EventLogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
require_once __DIR__ . '/EntiteController.php';
require_once __DIR__ . '/../Services/Security/SecurityMonitor.php';
@@ -55,14 +57,6 @@ class LoginController {
// admin accessible uniquement aux fk_role>1 (admins amicale + super-admins)
$roleCondition = ($interface === 'user') ? 'AND fk_role IN (1, 2)' : 'AND fk_role>1';
// Log pour le debug
LogService::log('Tentative de connexion GeoSector', [
'level' => 'info',
'username' => $username,
'type' => $interface,
'role_condition' => $roleCondition
]);
// Requête optimisée: on récupère l'utilisateur et son entité en une seule fois avec LEFT JOIN
$stmt = $this->db->prepare(
'SELECT
@@ -83,11 +77,8 @@ class LoginController {
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
SecurityMonitor::recordFailedLogin($clientIp, $username, 'user_not_found', $userAgent);
LogService::log('Tentative de connexion GeoSector échouée : utilisateur non trouvé', [
'level' => 'warning',
'username' => $username
]);
EventLogService::logLoginFailed($username, 'user_not_found', 1);
Response::json(['error' => 'Identifiants invalides'], 401);
return;
}
@@ -100,22 +91,15 @@ class LoginController {
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
SecurityMonitor::recordFailedLogin($clientIp, $username, 'invalid_password', $userAgent);
LogService::log('Tentative de connexion GeoSector échouée : mot de passe incorrect', [
'level' => 'warning',
'username' => $username
]);
EventLogService::logLoginFailed($username, 'invalid_password', 1);
Response::json(['error' => 'Identifiants invalides'], 401);
return;
}
// Vérifier si l'utilisateur a une entité et si elle est active
if (!empty($user['fk_entite']) && (!isset($user['entite_chk_active']) || $user['entite_chk_active'] != 1)) {
LogService::log('Tentative de connexion GeoSector échouée : entité non active', [
'level' => 'warning',
'username' => $username,
'entite_id' => $user['fk_entite']
]);
EventLogService::logLoginFailed($username, 'account_inactive', 1);
Response::json([
'status' => 'error',
'message' => 'Votre amicale n\'est pas activée. Veuillez contacter votre administrateur.'
@@ -307,16 +291,33 @@ class LoginController {
// Récupérer l'ID de l'opération active (première opération retournée)
$activeOperationId = $operations[0]['id'];
// Récupérer ope_user_id pour l'utilisateur connecté et l'opération active
$opeUserStmt = $this->db->prepare(
'SELECT id FROM ope_users WHERE fk_user = ? AND fk_operation = ?'
);
$opeUserStmt->execute([$user['id'], $activeOperationId]);
$opeUser = $opeUserStmt->fetch(PDO::FETCH_ASSOC);
if ($opeUser) {
$userData['ope_user_id'] = $opeUser['id'];
} else {
$userData['ope_user_id'] = null;
}
// 2. Récupérer les secteurs selon l'interface et le rôle
if ($interface === 'user') {
// Interface utilisateur : seulement les secteurs affectés à l'utilisateur
$sectorsStmt = $this->db->prepare(
'SELECT s.id, s.libelle, s.color, s.sector
FROM ope_sectors s
JOIN ope_users_sectors us ON s.id = us.fk_sector
WHERE us.fk_operation = ? AND us.fk_user = ? AND us.chk_active = 1 AND s.chk_active = 1'
);
$sectorsStmt->execute([$activeOperationId, $user['id']]);
// Utiliser ope_user_id au lieu de users.id
$opeUserId = $userData['ope_user_id'];
if ($opeUserId) {
$sectorsStmt = $this->db->prepare(
'SELECT s.id, s.libelle, s.color, s.sector
FROM ope_sectors s
JOIN ope_users_sectors us ON s.id = us.fk_sector
WHERE us.fk_operation = ? AND us.fk_user = ? AND us.chk_active = 1 AND s.chk_active = 1'
);
$sectorsStmt->execute([$activeOperationId, $opeUserId]);
}
} elseif ($interface === 'admin' && $user['fk_role'] == 2) {
// Interface admin avec rôle 2 : tous les secteurs distincts de l'opération
$sectorsStmt = $this->db->prepare(
@@ -344,11 +345,12 @@ class LoginController {
// 3. Récupérer les passages selon l'interface et le rôle
if ($interface === 'user' && !empty($sectors)) {
// Interface utilisateur : passages de l'utilisateur + passages à finaliser sur ses secteurs
$userId = $user['id'];
// Utiliser ope_user_id au lieu de users.id
$opeUserId = $userData['ope_user_id'];
$sectorIds = array_column($sectors, 'id');
$sectorIdsString = implode(',', $sectorIds);
if (!empty($sectorIdsString)) {
if (!empty($sectorIdsString) && $opeUserId) {
$passagesStmt = $this->db->prepare(
"SELECT id, fk_operation, fk_sector, fk_user, fk_type, fk_adresse, passed_at, numero, rue, rue_bis, ville, residence, fk_habitat, appt, niveau,
gps_lat, gps_lng, nom_recu, encrypted_name, remarque, encrypted_email, encrypted_phone, montant, fk_type_reglement, email_erreur, nb_passages
@@ -362,7 +364,7 @@ class LoginController {
)
ORDER BY passed_at DESC"
);
$passagesStmt->execute([$activeOperationId, $userId, $userId]);
$passagesStmt->execute([$activeOperationId, $opeUserId, $opeUserId]);
}
} elseif ($interface === 'admin' && $user['fk_role'] == 2) {
// Interface admin avec rôle 2 : tous les passages de l'opération
@@ -423,13 +425,14 @@ class LoginController {
if (!empty($sectorIdsString)) {
$usersSectorsStmt = $this->db->prepare(
"SELECT DISTINCT u.id, u.first_name, u.encrypted_name, u.sect_name, us.fk_sector
FROM users u
JOIN ope_users_sectors us ON u.id = us.fk_user
WHERE us.fk_sector IN ($sectorIdsString)
AND us.fk_operation = ?
AND us.chk_active = 1
AND u.chk_active = 1
"SELECT DISTINCT u.id as user_id, ou.id as ope_user_id, ou.first_name, u.encrypted_name, u.sect_name, us.fk_sector
FROM users u
JOIN ope_users ou ON u.id = ou.fk_user
JOIN ope_users_sectors us ON ou.id = us.fk_user AND ou.fk_operation = us.fk_operation
WHERE us.fk_sector IN ($sectorIdsString)
AND us.fk_operation = ?
AND us.chk_active = 1
AND u.chk_active = 1
AND u.id != ?" // Exclure l'utilisateur connecté
);
$usersSectorsStmt->execute([$activeOperationId, $user['id']]);
@@ -458,14 +461,27 @@ class LoginController {
// 6. Récupérer les membres (users de l'entité du user) si nécessaire
if ($interface === 'admin' && $user['fk_role'] == 2 && !empty($user['fk_entite'])) {
$membresStmt = $this->db->prepare(
'SELECT id, fk_role, fk_entite, fk_titre, encrypted_name, first_name, sect_name,
encrypted_user_name, encrypted_phone, encrypted_mobile, encrypted_email,
date_naissance, date_embauche, chk_active
FROM users
WHERE fk_entite = ?'
);
$membresStmt->execute([$user['fk_entite']]);
// Si on a une opération active, on récupère aussi ope_user_id
if (isset($activeOperationId)) {
$membresStmt = $this->db->prepare(
'SELECT u.id, u.fk_role, u.fk_entite, u.fk_titre, u.encrypted_name, u.first_name, u.sect_name,
u.encrypted_user_name, u.encrypted_phone, u.encrypted_mobile, u.encrypted_email,
u.date_naissance, u.date_embauche, u.chk_active, ou.id as ope_user_id
FROM users u
LEFT JOIN ope_users ou ON u.id = ou.fk_user AND ou.fk_operation = ?
WHERE u.fk_entite = ?'
);
$membresStmt->execute([$activeOperationId, $user['fk_entite']]);
} else {
$membresStmt = $this->db->prepare(
'SELECT id, fk_role, fk_entite, fk_titre, encrypted_name, first_name, sect_name,
encrypted_user_name, encrypted_phone, encrypted_mobile, encrypted_email,
date_naissance, date_embauche, chk_active
FROM users
WHERE fk_entite = ?'
);
$membresStmt->execute([$user['fk_entite']]);
}
$membres = $membresStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($membres)) {
@@ -474,6 +490,7 @@ class LoginController {
foreach ($membres as $membre) {
$membreItem = [
'id' => $membre['id'],
'ope_user_id' => $membre['ope_user_id'] ?? null,
'fk_role' => $membre['fk_role'],
'fk_entite' => $membre['fk_entite'],
'fk_titre' => $membre['fk_titre'],
@@ -537,13 +554,15 @@ class LoginController {
if ($user['fk_role'] <= 2) {
// User normal ou admin avec fk_role=2: uniquement son amicale
$amicaleStmt = $this->db->prepare(
'SELECT e.id, e.encrypted_name as name, e.adresse1, e.adresse2, e.code_postal, e.ville,
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass
'SELECT e.id, e.encrypted_name as name, e.adresse1, e.adresse2, e.code_postal, e.ville,
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
sa.stripe_account_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass,
sa.stripe_location_id
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
LEFT JOIN stripe_accounts sa ON e.id = sa.fk_entite
WHERE e.id = ? AND e.chk_active = 1'
);
$amicaleStmt->execute([$user['fk_entite']]);
@@ -551,13 +570,15 @@ class LoginController {
} else {
// Admin avec fk_role>2: toutes les amicales sauf id=1
$amicaleStmt = $this->db->prepare(
'SELECT e.id, e.encrypted_name as name, e.adresse1, e.adresse2, e.code_postal, e.ville,
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass
'SELECT e.id, e.encrypted_name as name, e.adresse1, e.adresse2, e.code_postal, e.ville,
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
sa.stripe_account_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass,
sa.stripe_location_id
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
LEFT JOIN stripe_accounts sa ON e.id = sa.fk_entite
WHERE e.id != 1 AND e.chk_active = 1'
);
$amicaleStmt->execute();
@@ -872,6 +893,9 @@ class LoginController {
// Ajouter les données du chat à la réponse
$response['chat'] = $chatData;
// Log de connexion réussie
EventLogService::logLoginSuccess($user['id'], $user['fk_entite'] ?? null, $username);
// Envoi de la réponse
Response::json($response);
} catch (PDOException $e) {
@@ -918,14 +942,6 @@ class LoginController {
// Déterminer le roleCondition selon le mode (même logique que login)
$roleCondition = ($mode === 'user') ? 'AND fk_role IN (1, 2)' : 'AND fk_role>1';
// Log pour le debug
LogService::log('Rafraîchissement session GeoSector', [
'level' => 'info',
'userId' => $userId,
'mode' => $mode,
'role_condition' => $roleCondition
]);
// 4. Requête pour récupérer l'utilisateur et son entité (même requête que login)
$stmt = $this->db->prepare(
'SELECT
@@ -1074,15 +1090,32 @@ class LoginController {
$activeOperationId = $operations[0]['id'];
// Récupérer ope_user_id pour l'utilisateur connecté et l'opération active
$opeUserStmt = $this->db->prepare(
'SELECT id FROM ope_users WHERE fk_user = ? AND fk_operation = ?'
);
$opeUserStmt->execute([$user['id'], $activeOperationId]);
$opeUser = $opeUserStmt->fetch(PDO::FETCH_ASSOC);
if ($opeUser) {
$userData['ope_user_id'] = $opeUser['id'];
} else {
$userData['ope_user_id'] = null;
}
// Récupérer les secteurs selon le mode et le rôle
if ($mode === 'user') {
$sectorsStmt = $this->db->prepare(
'SELECT s.id, s.libelle, s.color, s.sector
FROM ope_sectors s
JOIN ope_users_sectors us ON s.id = us.fk_sector
WHERE us.fk_operation = ? AND us.fk_user = ? AND us.chk_active = 1 AND s.chk_active = 1'
);
$sectorsStmt->execute([$activeOperationId, $user['id']]);
// Utiliser ope_user_id au lieu de users.id
$opeUserId = $userData['ope_user_id'];
if ($opeUserId) {
$sectorsStmt = $this->db->prepare(
'SELECT s.id, s.libelle, s.color, s.sector
FROM ope_sectors s
JOIN ope_users_sectors us ON s.id = us.fk_sector
WHERE us.fk_operation = ? AND us.fk_user = ? AND us.chk_active = 1 AND s.chk_active = 1'
);
$sectorsStmt->execute([$activeOperationId, $opeUserId]);
}
} elseif ($mode === 'admin' && $user['fk_role'] == 2) {
$sectorsStmt = $this->db->prepare(
'SELECT DISTINCT s.id, s.libelle, s.color, s.sector
@@ -1106,10 +1139,12 @@ class LoginController {
// Récupérer les passages selon le mode et le rôle
if ($mode === 'user' && !empty($sectors)) {
// Utiliser ope_user_id au lieu de users.id
$opeUserId = $userData['ope_user_id'];
$sectorIds = array_column($sectors, 'id');
$sectorIdsString = implode(',', $sectorIds);
if (!empty($sectorIdsString)) {
if (!empty($sectorIdsString) && $opeUserId) {
$passagesStmt = $this->db->prepare(
"SELECT id, fk_operation, fk_sector, fk_user, fk_type, fk_adresse, passed_at, numero, rue, rue_bis, ville, residence, fk_habitat, appt, niveau,
gps_lat, gps_lng, nom_recu, encrypted_name, remarque, encrypted_email, encrypted_phone, montant, fk_type_reglement, email_erreur, nb_passages
@@ -1123,7 +1158,7 @@ class LoginController {
)
ORDER BY passed_at DESC"
);
$passagesStmt->execute([$activeOperationId, $user['id'], $user['id']]);
$passagesStmt->execute([$activeOperationId, $opeUserId, $opeUserId]);
}
} elseif ($mode === 'admin' && $user['fk_role'] == 2) {
$passagesStmt = $this->db->prepare(
@@ -1177,9 +1212,10 @@ class LoginController {
if (!empty($sectorIdsString)) {
$usersSectorsStmt = $this->db->prepare(
"SELECT DISTINCT u.id, u.first_name, u.encrypted_name, u.sect_name, us.fk_sector
"SELECT DISTINCT u.id as user_id, ou.id as ope_user_id, ou.first_name, u.encrypted_name, u.sect_name, us.fk_sector
FROM users u
JOIN ope_users_sectors us ON u.id = us.fk_user
JOIN ope_users ou ON u.id = ou.fk_user
JOIN ope_users_sectors us ON ou.id = us.fk_user AND ou.fk_operation = us.fk_operation
WHERE us.fk_sector IN ($sectorIdsString)
AND us.fk_operation = ?
AND us.chk_active = 1
@@ -1209,20 +1245,34 @@ class LoginController {
// Récupérer les membres si nécessaire
$membresData = [];
if ($mode === 'admin' && $user['fk_role'] == 2 && !empty($user['fk_entite'])) {
$membresStmt = $this->db->prepare(
'SELECT id, fk_role, fk_entite, fk_titre, encrypted_name, first_name, sect_name,
encrypted_user_name, encrypted_phone, encrypted_mobile, encrypted_email,
date_naissance, date_embauche, chk_active
FROM users
WHERE fk_entite = ?'
);
$membresStmt->execute([$user['fk_entite']]);
// Si on a une opération active, on récupère aussi ope_user_id
if (isset($activeOperationId)) {
$membresStmt = $this->db->prepare(
'SELECT u.id, u.fk_role, u.fk_entite, u.fk_titre, u.encrypted_name, u.first_name, u.sect_name,
u.encrypted_user_name, u.encrypted_phone, u.encrypted_mobile, u.encrypted_email,
u.date_naissance, u.date_embauche, u.chk_active, ou.id as ope_user_id
FROM users u
LEFT JOIN ope_users ou ON u.id = ou.fk_user AND ou.fk_operation = ?
WHERE u.fk_entite = ?'
);
$membresStmt->execute([$activeOperationId, $user['fk_entite']]);
} else {
$membresStmt = $this->db->prepare(
'SELECT id, fk_role, fk_entite, fk_titre, encrypted_name, first_name, sect_name,
encrypted_user_name, encrypted_phone, encrypted_mobile, encrypted_email,
date_naissance, date_embauche, chk_active
FROM users
WHERE fk_entite = ?'
);
$membresStmt->execute([$user['fk_entite']]);
}
$membres = $membresStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($membres)) {
foreach ($membres as $membre) {
$membreItem = [
'id' => $membre['id'],
'ope_user_id' => $membre['ope_user_id'] ?? null,
'fk_role' => $membre['fk_role'],
'fk_entite' => $membre['fk_entite'],
'fk_titre' => $membre['fk_titre'],
@@ -1279,10 +1329,12 @@ class LoginController {
'SELECT e.id, e.encrypted_name as name, e.adresse1, e.adresse2, e.code_postal, e.ville,
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass
sa.stripe_account_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass,
sa.stripe_location_id
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
LEFT JOIN stripe_accounts sa ON e.id = sa.fk_entite
WHERE e.id = ? AND e.chk_active = 1'
);
$amicaleStmt->execute([$user['fk_entite']]);
@@ -1292,10 +1344,12 @@ class LoginController {
'SELECT e.id, e.encrypted_name as name, e.adresse1, e.adresse2, e.code_postal, e.ville,
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass
sa.stripe_account_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass,
sa.stripe_location_id
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
LEFT JOIN stripe_accounts sa ON e.id = sa.fk_entite
WHERE e.id != 1 AND e.chk_active = 1'
);
$amicaleStmt->execute();
@@ -1830,13 +1884,13 @@ class LoginController {
}
*/
// 5. Vérification de l'existence du code postal dans la table entites
$checkPostalStmt = $this->db->prepare('SELECT id FROM entites WHERE code_postal = ?');
$checkPostalStmt->execute([$postalCode]);
// 5. Vérification de l'existence du code postal + ville dans la table entites
$checkPostalStmt = $this->db->prepare('SELECT id FROM entites WHERE code_postal = ? AND ville = ?');
$checkPostalStmt->execute([$postalCode, $cityName]);
if ($checkPostalStmt->fetch()) {
Response::json([
'status' => 'error',
'message' => 'Une amicale est déjà inscrite à ce code postal'
'message' => 'Une amicale est déjà inscrite pour ce code postal et cette ville'
], 409);
return;
}
@@ -2073,16 +2127,15 @@ class LoginController {
// Méthodes auxiliaires
public function logout(): void {
$userId = Session::getUserId() ?? null;
$userEmail = Session::getUserEmail() ?? 'anonyme';
$userId = Session::getUserId();
$entityId = Session::getEntityId();
Session::logout();
LogService::log('Déconnexion GeoSector réussie', [
'level' => 'info',
'userId' => $userId,
'email' => $userEmail
]);
// Log de déconnexion
if ($userId) {
EventLogService::logLogout($userId, $entityId, 0);
}
// Retourner une réponse standardisée
Response::json([
@@ -2106,12 +2159,20 @@ class LoginController {
// Formater la ville et le code postal pour la recherche
$citySearch = urlencode($cityName . ' ' . $postalCode);
// Créer un contexte avec timeout de 2 secondes
$context = stream_context_create([
'http' => [
'timeout' => 2,
'ignore_errors' => true
]
]);
foreach ($keywords as $keyword) {
// Construire l'URL de recherche pour l'API adresse.gouv.fr
$searchUrl = "https://api-adresse.data.gouv.fr/search/?q=" . urlencode($keyword) . "+$citySearch&limit=5";
// Effectuer la requête HTTP
$response = @file_get_contents($searchUrl);
// Effectuer la requête HTTP avec timeout
$response = @file_get_contents($searchUrl, false, $context);
if ($response === false) {
LogService::log('Erreur lors de la requête à l\'API adresse.gouv.fr', [
@@ -2159,9 +2220,19 @@ class LoginController {
}
}
// Si aucune caserne n'a été trouvée avec les mots-clés, utiliser les coordonnées du centre de la ville
// Si aucune caserne trouvée, chercher simplement ville + code postal avec timeout
$citySearch = urlencode($cityName . ' ' . $postalCode);
$cityUrl = "https://api-adresse.data.gouv.fr/search/?q=$citySearch&limit=1";
$cityResponse = @file_get_contents($cityUrl);
// Créer un contexte avec timeout de 2 secondes
$context = stream_context_create([
'http' => [
'timeout' => 2,
'ignore_errors' => true
]
]);
$cityResponse = @file_get_contents($cityUrl, false, $context);
if ($cityResponse !== false) {
$cityData = json_decode($cityResponse, true);
@@ -2169,7 +2240,7 @@ class LoginController {
if ($cityData && isset($cityData['features'][0]['geometry']['coordinates'])) {
$coordinates = $cityData['features'][0]['geometry']['coordinates'];
LogService::log('Utilisation des coordonnées du centre de la ville', [
LogService::log('Coordonnées GPS récupérées pour l\'adresse', [
'level' => 'info',
'city' => $cityName,
'postalCode' => $postalCode
@@ -2183,6 +2254,12 @@ class LoginController {
}
// Aucune coordonnée trouvée
LogService::log('Aucune coordonnée GPS trouvée (timeout ou adresse invalide)', [
'level' => 'warning',
'city' => $cityName,
'postalCode' => $postalCode
]);
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,544 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/MigrationService.php';
use PDO;
use PDOException;
use Database;
use AppConfig;
use Request;
use Response;
use Session;
use App\Services\LogService;
use App\Services\MigrationService;
use Exception;
class MigrationController {
private PDO $db;
private AppConfig $appConfig;
private MigrationService $migrationService;
public function __construct() {
$this->db = Database::getInstance();
$this->appConfig = AppConfig::getInstance();
$this->migrationService = new MigrationService();
}
/**
* Teste les connexions aux bases de données source et cible
*
* GET /api/migrations/test-connections
*
* @return void
*/
public function testConnections(): void {
try {
$result = $this->migrationService->testConnections();
Response::json([
'status' => 'success',
'connections' => $result
]);
} catch (Exception $e) {
LogService::log('Erreur lors du test des connexions', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Liste les entités disponibles à migrer depuis la base source
*
* GET /api/migrations/entities/available
*
* @return void
*/
public function getAvailableEntities(): void {
try {
$entities = $this->migrationService->getAvailableEntities();
Response::json([
'status' => 'success',
'count' => count($entities),
'entities' => $entities
]);
} catch (Exception $e) {
LogService::log('Erreur lors de la récupération des entités disponibles', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Récupère les détails d'une entité source
*
* GET /api/migrations/entities/:id
*
* @param int $id ID de l'entité dans la base source
* @return void
*/
public function getEntityDetails(int $id): void {
try {
$entity = $this->migrationService->getEntityDetails($id);
if (!$entity) {
Response::json([
'status' => 'error',
'message' => 'Entité non trouvée'
], 404);
return;
}
Response::json([
'status' => 'success',
'entity' => $entity
]);
} catch (Exception $e) {
LogService::log('Erreur lors de la récupération des détails de l\'entité', [
'level' => 'error',
'error' => $e->getMessage(),
'entity_id' => $id
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Migre une entité complète ou par étapes
*
* POST /api/migrations/entity
* Body: {
* "entity_id": 45,
* "steps": ["users", "operations"], // Optionnel
* "dry_run": false, // Optionnel
* "truncate": false // Optionnel
* }
*
* @return void
*/
public function migrateEntity(): void {
try {
$data = Request::getJsonBody();
// Validation
if (!isset($data['entity_id'])) {
Response::json([
'status' => 'error',
'message' => 'Le champ entity_id est requis'
], 400);
return;
}
$entityId = (int) $data['entity_id'];
$steps = $data['steps'] ?? null;
$dryRun = $data['dry_run'] ?? false;
$truncate = $data['truncate'] ?? false;
// Vérifier les permissions (admin uniquement)
$userRole = Session::get('fk_role');
if ($userRole != 3) { // 3 = admin
Response::json([
'status' => 'error',
'message' => 'Accès refusé. Cette action nécessite les droits administrateur.'
], 403);
return;
}
LogService::log('Début de migration d\'entité', [
'level' => 'info',
'entity_id' => $entityId,
'steps' => $steps,
'dry_run' => $dryRun,
'truncate' => $truncate,
'user_id' => Session::get('user_id')
]);
// Exécuter la migration
$result = $this->migrationService->migrateEntity(
$entityId,
$steps,
$dryRun,
$truncate
);
Response::json([
'status' => 'success',
'entity_id' => $entityId,
'entity_name' => $result['entity_name'],
'migration_id' => $result['migration_id'],
'steps_completed' => $result['steps_completed'],
'total_duration_ms' => $result['total_duration_ms'],
'summary' => $result['summary']
]);
} catch (Exception $e) {
LogService::log('Erreur lors de la migration d\'entité', [
'level' => 'error',
'error' => $e->getMessage(),
'entity_id' => $data['entity_id'] ?? null
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Migre une étape spécifique pour une entité
*
* POST /api/migrations/entity/step
* Body: {
* "entity_id": 45,
* "step": "users",
* "dry_run": false,
* "options": {}
* }
*
* @return void
*/
public function migrateEntityStep(): void {
try {
$data = Request::getJsonBody();
// Validation
if (!isset($data['entity_id']) || !isset($data['step'])) {
Response::json([
'status' => 'error',
'message' => 'Les champs entity_id et step sont requis'
], 400);
return;
}
$entityId = (int) $data['entity_id'];
$step = $data['step'];
$dryRun = $data['dry_run'] ?? false;
$options = $data['options'] ?? [];
// Vérifier les permissions (admin uniquement)
$userRole = Session::get('fk_role');
if ($userRole != 3) {
Response::json([
'status' => 'error',
'message' => 'Accès refusé. Cette action nécessite les droits administrateur.'
], 403);
return;
}
LogService::log('Début de migration d\'étape', [
'level' => 'info',
'entity_id' => $entityId,
'step' => $step,
'dry_run' => $dryRun,
'options' => $options,
'user_id' => Session::get('user_id')
]);
// Exécuter l'étape de migration
$result = $this->migrationService->migrateStep(
$entityId,
$step,
$dryRun,
$options
);
Response::json([
'status' => 'success',
'entity_id' => $entityId,
'step' => $step,
'records_migrated' => $result['records_migrated'],
'duration_ms' => $result['duration_ms'],
'warnings' => $result['warnings'] ?? [],
'details' => $result['details'] ?? []
]);
} catch (Exception $e) {
LogService::log('Erreur lors de la migration d\'étape', [
'level' => 'error',
'error' => $e->getMessage(),
'entity_id' => $data['entity_id'] ?? null,
'step' => $data['step'] ?? null
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Récupère le statut de migration d'une entité
*
* GET /api/migrations/entity/:id/status
*
* @param int $id ID de l'entité
* @return void
*/
public function getMigrationStatus(int $id): void {
try {
$status = $this->migrationService->getMigrationStatus($id);
Response::json([
'status' => 'success',
'entity_id' => $id,
'migration_status' => $status
]);
} catch (Exception $e) {
LogService::log('Erreur lors de la récupération du statut de migration', [
'level' => 'error',
'error' => $e->getMessage(),
'entity_id' => $id
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Récupère les logs de migration d'une entité
*
* GET /api/migrations/entity/:id/logs
*
* @param int $id ID de l'entité
* @return void
*/
public function getMigrationLogs(int $id): void {
try {
$logs = $this->migrationService->getMigrationLogs($id);
Response::json([
'status' => 'success',
'entity_id' => $id,
'logs' => $logs
]);
} catch (Exception $e) {
LogService::log('Erreur lors de la récupération des logs de migration', [
'level' => 'error',
'error' => $e->getMessage(),
'entity_id' => $id
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Génère un rapport de migration pour une entité
*
* GET /api/migrations/entity/:id/report
*
* @param int $id ID de l'entité
* @return void
*/
public function getMigrationReport(int $id): void {
try {
$report = $this->migrationService->generateMigrationReport($id);
Response::json([
'status' => 'success',
'entity_id' => $id,
'report' => $report
]);
} catch (Exception $e) {
LogService::log('Erreur lors de la génération du rapport de migration', [
'level' => 'error',
'error' => $e->getMessage(),
'entity_id' => $id
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Compare les données source vs cible pour une entité
*
* GET /api/migrations/entity/:id/compare
*
* @param int $id ID de l'entité
* @return void
*/
public function compareEntityData(int $id): void {
try {
$comparison = $this->migrationService->compareEntityData($id);
Response::json([
'status' => 'success',
'entity_id' => $id,
'comparison' => $comparison
]);
} catch (Exception $e) {
LogService::log('Erreur lors de la comparaison des données', [
'level' => 'error',
'error' => $e->getMessage(),
'entity_id' => $id
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Vérifie l'intégrité des données migrées pour une entité
*
* GET /api/migrations/entity/:id/verify
*
* @param int $id ID de l'entité
* @return void
*/
public function verifyMigration(int $id): void {
try {
$verification = $this->migrationService->verifyMigration($id);
Response::json([
'status' => 'success',
'entity_id' => $id,
'verification' => $verification
]);
} catch (Exception $e) {
LogService::log('Erreur lors de la vérification de la migration', [
'level' => 'error',
'error' => $e->getMessage(),
'entity_id' => $id
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Annule la migration d'une entité (rollback)
*
* DELETE /api/migrations/entity/:id
*
* @param int $id ID de l'entité
* @return void
*/
public function rollbackEntity(int $id): void {
try {
// Vérifier les permissions (admin uniquement)
$userRole = Session::get('fk_role');
if ($userRole != 3) {
Response::json([
'status' => 'error',
'message' => 'Accès refusé. Cette action nécessite les droits administrateur.'
], 403);
return;
}
LogService::log('Début de rollback d\'entité', [
'level' => 'warning',
'entity_id' => $id,
'user_id' => Session::get('user_id')
]);
$result = $this->migrationService->rollbackEntity($id);
Response::json([
'status' => 'success',
'entity_id' => $id,
'message' => 'Migration annulée avec succès',
'deleted_records' => $result['deleted_records']
]);
} catch (Exception $e) {
LogService::log('Erreur lors du rollback de la migration', [
'level' => 'error',
'error' => $e->getMessage(),
'entity_id' => $id
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Supprime une étape spécifique de la migration
*
* DELETE /api/migrations/entity/:id/step/:step
*
* @param int $id ID de l'entité
* @param string $step Nom de l'étape
* @return void
*/
public function rollbackStep(int $id, string $step): void {
try {
// Vérifier les permissions (admin uniquement)
$userRole = Session::get('fk_role');
if ($userRole != 3) {
Response::json([
'status' => 'error',
'message' => 'Accès refusé. Cette action nécessite les droits administrateur.'
], 403);
return;
}
LogService::log('Début de rollback d\'étape', [
'level' => 'warning',
'entity_id' => $id,
'step' => $step,
'user_id' => Session::get('user_id')
]);
$result = $this->migrationService->rollbackStep($id, $step);
Response::json([
'status' => 'success',
'entity_id' => $id,
'step' => $step,
'message' => 'Étape annulée avec succès',
'deleted_records' => $result['deleted_records']
]);
} catch (Exception $e) {
LogService::log('Erreur lors du rollback de l\'étape', [
'level' => 'error',
'error' => $e->getMessage(),
'entity_id' => $id,
'step' => $step
]);
Response::json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/EventLogService.php';
require_once __DIR__ . '/../Services/ExportService.php';
require_once __DIR__ . '/../Services/ApiService.php';
require_once __DIR__ . '/../Services/OperationDataService.php';
@@ -16,10 +17,11 @@ use AppConfig;
use Request;
use Response;
use Session;
use LogService;
use ExportService;
use ApiService;
use OperationDataService;
use App\Services\LogService;
use App\Services\EventLogService;
use App\Services\ExportService;
use App\Services\ApiService;
use App\Services\OperationDataService;
use Exception;
use DateTime;
@@ -378,34 +380,37 @@ class OperationController {
$newSectId = (int)$this->db->lastInsertId();
$duplicatedSectors++;
// Étape 4.3 : Dupliquer les users_sectors en vérifiant que fk_user existe dans ope_users
// Étape 4.3 : Dupliquer les users_sectors en convertissant ancien ope_users.id → nouvel ope_users.id
$stmt = $this->db->prepare('
INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, fk_user_creat)
SELECT ?, ous.fk_user, ?, ?
SELECT ?, new_ou.id, ?, ?
FROM ope_users_sectors ous
INNER JOIN ope_users ou ON ou.fk_user = ous.fk_user AND ou.fk_operation = ?
INNER JOIN ope_users old_ou ON old_ou.id = ous.fk_user AND old_ou.fk_operation = ?
INNER JOIN ope_users new_ou ON new_ou.fk_user = old_ou.fk_user AND new_ou.fk_operation = ?
WHERE ous.fk_operation = ? AND ous.fk_sector = ? AND ous.chk_active = 1
');
$stmt->execute([$newOpeId, $newSectId, $userId, $newOpeId, $oldOpeId, $oldSectId]);
$stmt->execute([$newOpeId, $newSectId, $userId, $oldOpeId, $newOpeId, $oldOpeId, $oldSectId]);
$duplicatedUsersSectors += $stmt->rowCount();
// Étape 4.4 : Dupliquer les passages avec les valeurs par défaut spécifiées
// Étape 4.4 : Dupliquer les passages en convertissant ancien ope_users.id → nouvel ope_users.id
$stmt = $this->db->prepare('
INSERT INTO ope_pass (
fk_operation, fk_sector, fk_user, fk_adresse, numero, rue, rue_bis, ville,
fk_operation, fk_sector, fk_user, fk_adresse, numero, rue, rue_bis, ville,
fk_habitat, appt, niveau, residence, gps_lat, gps_lng, encrypted_name,
fk_type, passed_at, montant, fk_type_reglement, chk_email_sent, chk_striped,
docremis, nb_passages, chk_map_create, chk_mobile, chk_synchro, anomalie,
fk_type, passed_at, montant, fk_type_reglement, chk_email_sent, chk_striped,
docremis, nb_passages, chk_map_create, chk_mobile, chk_synchro, anomalie,
fk_user_creat, chk_active
)
SELECT
?, ?, fk_user, fk_adresse, numero, rue, rue_bis, ville,
fk_habitat, appt, niveau, residence, gps_lat, gps_lng, encrypted_name,
2, NULL, 0, 4, 0, 0, 0, 1, 0, 0, 1, 0, ?, 1
FROM ope_pass
WHERE fk_operation = ? AND fk_sector = ? AND chk_active = 1
SELECT
?, ?, new_ou.id, op.fk_adresse, op.numero, op.rue, op.rue_bis, op.ville,
op.fk_habitat, op.appt, op.niveau, op.residence, op.gps_lat, op.gps_lng, op.encrypted_name,
2, NULL, 0, 4, 0, 0, 0, 0, 0, 0, 1, 0, ?, 1
FROM ope_pass op
INNER JOIN ope_users old_ou ON old_ou.id = op.fk_user AND old_ou.fk_operation = ?
INNER JOIN ope_users new_ou ON new_ou.fk_user = old_ou.fk_user AND new_ou.fk_operation = ?
WHERE op.fk_operation = ? AND op.fk_sector = ? AND op.chk_active = 1
');
$stmt->execute([$newOpeId, $newSectId, $userId, $oldOpeId, $oldSectId]);
$stmt->execute([$newOpeId, $newSectId, $userId, $oldOpeId, $newOpeId, $oldOpeId, $oldSectId]);
$duplicatedPassages += $stmt->rowCount();
}
@@ -455,19 +460,12 @@ class OperationController {
// Étape 7 : Préparer la réponse avec les groupes JSON
$response = OperationDataService::prepareOperationResponse($this->db, $newOpeId, $entiteId);
LogService::log('Création opération terminée avec succès', [
'level' => 'info',
'userId' => $userId,
'entiteId' => $entiteId,
'newOpeId' => $newOpeId,
'oldOpeId' => $oldOpeId,
'stats' => [
'insertedUsers' => $insertedUsers,
'duplicatedSectors' => $duplicatedSectors,
'duplicatedUsersSectors' => $duplicatedUsersSectors,
'duplicatedPassages' => $duplicatedPassages
]
]);
// Log de création de l'opération
EventLogService::logOperationCreated(
$newOpeId,
$data['date_deb'],
$data['date_fin']
);
Response::json($response, 201);
} catch (Exception $e) {
@@ -621,12 +619,24 @@ class OperationController {
$operationId
]);
LogService::log('Mise à jour d\'une opération', [
'level' => 'info',
'userId' => $userId,
'entiteId' => $entiteId,
'operationId' => $operationId
]);
// Log de mise à jour de l'opération
$changes = [];
if (isset($data['libelle']) || isset($data['name'])) {
$changes['libelle'] = ['new' => $libelle];
}
if (isset($data['date_deb'])) {
$changes['date_deb'] = ['new' => $data['date_deb']];
}
if (isset($data['date_fin'])) {
$changes['date_fin'] = ['new' => $data['date_fin']];
}
if (isset($data['chk_distinct_sectors'])) {
$changes['chk_distinct_sectors'] = ['new' => (int)$data['chk_distinct_sectors']];
}
if (!empty($changes)) {
EventLogService::logOperationUpdated($operationId, $changes);
}
Response::json([
'status' => 'success',
@@ -820,25 +830,8 @@ class OperationController {
// Valider la transaction
$this->db->commit();
LogService::log('Suppression complète d\'une opération et de toutes ses données', [
'level' => 'info',
'userId' => $userId,
'userRole' => $userRole,
'userEntiteId' => $userEntiteId,
'operationEntiteId' => $operationEntiteId,
'operationId' => $operationId,
'operationActive' => $operationActive,
'deletedCounts' => [
'medias' => $deletedMedias,
'ope_pass_histo' => $deletedPassHisto,
'ope_pass' => $deletedPass,
'ope_users_sectors' => $deletedUsersSectors,
'sectors_adresses' => $deletedSectorsAdresses,
'ope_sectors' => $deletedSectors,
'ope_users' => $deletedUsers,
'operations' => 1
]
]);
// Log de suppression de l'opération (suppression physique)
EventLogService::logOperationDeleted($operationId, false);
// Préparer la réponse selon le statut de l'opération supprimée
$response = [
@@ -948,13 +941,14 @@ class OperationController {
// Récupérer les relations utilisateurs-secteurs
$stmt = $this->db->prepare('
SELECT
SELECT
ous.id, ous.fk_operation, ous.fk_user, ous.fk_sector,
ous.created_at, ous.updated_at, ous.chk_active,
u.encrypted_name as user_name, u.first_name as user_first_name,
u.encrypted_name as user_name, ou.first_name as user_first_name,
s.libelle as sector_name
FROM ope_users_sectors ous
INNER JOIN users u ON u.id = ous.fk_user
INNER JOIN ope_users ou ON ou.id = ous.fk_user
INNER JOIN users u ON u.id = ou.fk_user
INNER JOIN ope_sectors s ON s.id = ous.fk_sector
WHERE ous.fk_operation = ? AND ous.chk_active = 1
ORDER BY s.libelle, u.encrypted_name

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/EventLogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
require_once __DIR__ . '/../Services/ReceiptService.php';
@@ -15,8 +16,9 @@ use AppConfig;
use Request;
use Response;
use Session;
use LogService;
use ApiService;
use App\Services\LogService;
use App\Services\EventLogService;
use App\Services\ApiService;
use Exception;
use DateTime;
@@ -233,13 +235,14 @@ class PassageController {
p.fk_habitat, p.appt, p.niveau, p.residence, p.gps_lat, p.gps_lng,
p.encrypted_name, p.montant, p.fk_type_reglement, p.remarque,
p.encrypted_email, p.encrypted_phone, p.nom_recu, p.date_recu,
p.chk_email_sent, p.stripe_payment_id, p.docremis, p.date_repasser, p.nb_passages,
p.chk_email_sent, p.stripe_payment_id, p.stripe_payment_link_id, p.docremis, p.date_repasser, p.nb_passages,
p.chk_mobile, p.anomalie, p.created_at, p.updated_at, p.chk_active,
o.libelle as operation_libelle,
u.encrypted_name as user_name, u.first_name as user_first_name
u.encrypted_name as user_name, ou.first_name as user_first_name
FROM ope_pass p
INNER JOIN operations o ON p.fk_operation = o.id
INNER JOIN users u ON p.fk_user = u.id
INNER JOIN ope_users ou ON p.fk_user = ou.id
INNER JOIN users u ON ou.fk_user = u.id
WHERE $whereClause AND p.chk_active = 1
ORDER BY p.created_at DESC
LIMIT ? OFFSET ?
@@ -324,13 +327,14 @@ class PassageController {
$passageId = (int)$id;
$stmt = $this->db->prepare('
SELECT
p.*,
SELECT
p.*,
o.libelle as operation_libelle,
u.encrypted_name as user_name, u.first_name as user_first_name
u.encrypted_name as user_name, ou.first_name as user_first_name
FROM ope_pass p
INNER JOIN operations o ON p.fk_operation = o.id
INNER JOIN users u ON p.fk_user = u.id
INNER JOIN ope_users ou ON p.fk_user = ou.id
INNER JOIN users u ON ou.fk_user = u.id
WHERE p.id = ? AND o.fk_entite = ? AND p.chk_active = 1
');
@@ -410,12 +414,13 @@ class PassageController {
p.id, p.fk_operation, p.fk_sector, p.fk_user, p.passed_at,
p.numero, p.rue, p.rue_bis, p.ville, p.gps_lat, p.gps_lng,
p.encrypted_name, p.montant, p.fk_type_reglement, p.remarque,
p.encrypted_email, p.encrypted_phone, p.stripe_payment_id, p.chk_email_sent,
p.encrypted_email, p.encrypted_phone, p.stripe_payment_id, p.stripe_payment_link_id, p.chk_email_sent,
p.docremis, p.date_repasser, p.nb_passages, p.chk_mobile,
p.anomalie, p.created_at, p.updated_at,
u.encrypted_name as user_name, u.first_name as user_first_name
u.encrypted_name as user_name, ou.first_name as user_first_name
FROM ope_pass p
INNER JOIN users u ON p.fk_user = u.id
INNER JOIN ope_users ou ON p.fk_user = ou.id
INNER JOIN users u ON ou.fk_user = u.id
WHERE p.fk_operation = ? AND p.chk_active = 1
ORDER BY p.created_at DESC
LIMIT ? OFFSET ?
@@ -510,6 +515,24 @@ class PassageController {
return;
}
// Récupérer ope_users.id pour l'utilisateur du passage
// $data['fk_user'] contient users.id, on doit le convertir en ope_users.id
$passageUserId = (int)$data['fk_user'];
$stmtOpeUser = $this->db->prepare('
SELECT id FROM ope_users
WHERE fk_user = ? AND fk_operation = ?
');
$stmtOpeUser->execute([$passageUserId, $operationId]);
$opeUserId = $stmtOpeUser->fetchColumn();
if (!$opeUserId) {
Response::json([
'status' => 'error',
'message' => 'Utilisateur non trouvé dans cette opération'
], 404);
return;
}
// Chiffrement des données sensibles
$encryptedName = '';
if (isset($data['name']) && !empty(trim($data['name']))) {
@@ -527,7 +550,7 @@ class PassageController {
$insertData = [
'fk_operation' => $operationId,
'fk_sector' => isset($data['fk_sector']) ? (int)$data['fk_sector'] : 0,
'fk_user' => (int)$data['fk_user'],
'fk_user' => $opeUserId,
'fk_adresse' => $data['fk_adresse'] ?? '',
'passed_at' => isset($data['passed_at']) ? $data['passed_at'] : null,
'fk_type' => isset($data['fk_type']) ? (int)$data['fk_type'] : 0,
@@ -569,12 +592,14 @@ class PassageController {
$passageId = $this->db->lastInsertId();
LogService::log('Création d\'un nouveau passage', [
'level' => 'info',
'userId' => $userId,
'passageId' => $passageId,
'operationId' => $operationId
]);
// Log de création du passage
EventLogService::logPassageCreated(
(int)$passageId,
$insertData['fk_operation'],
$insertData['fk_sector'],
$insertData['montant'],
(string)$insertData['fk_type_reglement']
);
// Enregistrer la génération du reçu dans shutdown_function pour garantir son exécution
// Même si le worker FPM est tué après fastcgi_finish_request()
@@ -702,16 +727,33 @@ class PassageController {
return;
}
// Récupérer ope_users.id pour l'utilisateur connecté
$operationId = $passage['fk_operation'];
$stmtCurrentOpeUser = $this->db->prepare('
SELECT id FROM ope_users
WHERE fk_user = ? AND fk_operation = ?
');
$stmtCurrentOpeUser->execute([$userId, $operationId]);
$currentOpeUserId = $stmtCurrentOpeUser->fetchColumn();
if (!$currentOpeUserId) {
Response::json([
'status' => 'error',
'message' => 'Utilisateur connecté non trouvé dans cette opération'
], 404);
return;
}
// Si le passage était de type 2 et que l'utilisateur actuel est différent du créateur
// On force l'attribution du passage à l'utilisateur actuel
if ((int)$passage['fk_type'] === 2 && (int)$passage['fk_user'] !== $userId) {
$data['fk_user'] = $userId;
if ((int)$passage['fk_type'] === 2 && (int)$passage['fk_user'] !== $currentOpeUserId) {
$data['fk_user'] = $currentOpeUserId;
LogService::log('Attribution automatique d\'un passage type 2 à l\'utilisateur', [
'level' => 'info',
'passageId' => $passageId,
'ancien_user' => $passage['fk_user'],
'nouveau_user' => $userId
'nouveau_user' => $currentOpeUserId
]);
}
@@ -722,7 +764,7 @@ class PassageController {
// Champs pouvant être mis à jour
$updatableFields = [
'fk_sector',
'fk_user',
// Note: fk_user est traité séparément pour conversion users.id -> ope_users.id
'fk_adresse',
'passed_at',
'fk_type',
@@ -740,6 +782,7 @@ class PassageController {
'fk_type_reglement',
'remarque',
'stripe_payment_id',
'stripe_payment_link_id',
'nom_recu',
'date_recu',
'docremis',
@@ -756,6 +799,48 @@ class PassageController {
}
}
// Traitement spécial pour fk_user : conversion users.id -> ope_users.id
if (isset($data['fk_user'])) {
// Si $data['fk_user'] vient de l'attribution automatique, c'est déjà ope_users.id
// Sinon, on doit convertir users.id en ope_users.id
$providedUserId = (int)$data['fk_user'];
// Vérifier si c'est déjà un ope_users.id valide
$stmtCheckOpeUser = $this->db->prepare('
SELECT id FROM ope_users
WHERE id = ? AND fk_operation = ?
');
$stmtCheckOpeUser->execute([$providedUserId, $operationId]);
$isOpeUserId = $stmtCheckOpeUser->fetchColumn();
if ($isOpeUserId) {
// C'est déjà un ope_users.id valide
$updateFields[] = "fk_user = ?";
$params[] = $providedUserId;
} else {
// C'est probablement un users.id, on le convertit
$stmtGetOpeUser = $this->db->prepare('
SELECT id FROM ope_users
WHERE fk_user = ? AND fk_operation = ?
');
$stmtGetOpeUser->execute([$providedUserId, $operationId]);
$convertedOpeUserId = $stmtGetOpeUser->fetchColumn();
if ($convertedOpeUserId) {
$updateFields[] = "fk_user = ?";
$params[] = $convertedOpeUserId;
} else {
// Utilisateur non trouvé, on ignore cette mise à jour
LogService::log('Tentative de mise à jour avec un utilisateur invalide', [
'level' => 'warning',
'passageId' => $passageId,
'provided_user_id' => $providedUserId,
'operation_id' => $operationId
]);
}
}
}
// Gestion des champs chiffrés
if (array_key_exists('name', $data)) {
$updateFields[] = "encrypted_name = ?";
@@ -791,11 +876,21 @@ class PassageController {
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
LogService::log('Mise à jour d\'un passage', [
'level' => 'info',
'userId' => $userId,
'passageId' => $passageId
]);
// Log de mise à jour du passage (changements simplifiés)
$changes = [];
foreach ($data as $key => $value) {
// Ne logger que les champs non sensibles
if (!in_array($key, ['name', 'email', 'phone', 'encrypted_name', 'encrypted_email', 'encrypted_phone'])) {
$changes[$key] = ['new' => $value];
} else {
// Indiquer qu'un champ chiffré a été modifié
$changes[$key] = true;
}
}
if (!empty($changes)) {
EventLogService::logPassageUpdated((int)$passageId, $changes);
}
// Enregistrer la génération du reçu dans shutdown_function pour garantir son exécution
// Même si le worker FPM est tué après fastcgi_finish_request()
@@ -944,7 +1039,7 @@ class PassageController {
}
$stmt = $this->db->prepare('
SELECT p.id
SELECT p.id, p.fk_operation
FROM ope_pass p
INNER JOIN operations o ON p.fk_operation = o.id
WHERE p.id = ? AND o.fk_entite = ? AND p.chk_active = 1
@@ -962,18 +1057,19 @@ class PassageController {
// Désactiver le passage (soft delete)
$stmt = $this->db->prepare('
UPDATE ope_pass
UPDATE ope_pass
SET chk_active = 0, updated_at = NOW(), fk_user_modif = ?
WHERE id = ?
');
$stmt->execute([$userId, $passageId]);
LogService::log('Suppression d\'un passage', [
'level' => 'info',
'userId' => $userId,
'passageId' => $passageId
]);
// Log de suppression du passage
EventLogService::logPassageDeleted(
$passageId,
(int)$passage['fk_operation'],
true // soft delete
);
Response::json([
'status' => 'success',

View File

@@ -9,7 +9,7 @@ require_once __DIR__ . '/../Services/LogService.php';
use Request;
use Response;
use LogService;
use App\Services\LogService;
use App\Services\PasswordSecurityService;
/**

View File

@@ -3,14 +3,14 @@ namespace App\Controllers;
use Database;
use Response;
use LogService;
use ApiService;
use AddressService;
use DepartmentBoundaryService;
use App\Services\LogService;
use App\Services\EventLogService;
use App\Services\ApiService;
use App\Services\AddressService;
use App\Services\DepartmentBoundaryService;
require_once __DIR__ . '/../Services/EventLogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
require_once __DIR__ . '/../Services/AddressService.php';
require_once __DIR__ . '/../Services/DepartmentBoundaryService.php';
class SectorController
{
@@ -193,14 +193,31 @@ class SectorController
// Affectation des users si fournis
if (!empty($users)) {
$queryMember = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, fk_user_creat, chk_active)
VALUES (:operation_id, :user_id, :sector_id, NOW(), :user_creat, 1)";
$queryMember = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, updated_at, fk_user_creat, chk_active)
VALUES (:operation_id, :user_id, :sector_id, NOW(), NOW(), :user_creat, 1)";
$stmtMember = $this->db->prepare($queryMember);
foreach ($users as $memberId) {
// $memberId est DÉJÀ ope_users.id (envoyé par Flutter)
// Vérifier que cet ope_users.id existe et appartient bien à l'opération
$stmtOpeUser = $this->db->prepare('
SELECT id FROM ope_users
WHERE id = ? AND fk_operation = ?
');
$stmtOpeUser->execute([$memberId, $operationId]);
$opeUserId = $stmtOpeUser->fetchColumn();
if (!$opeUserId) {
$this->logService->warning('ope_users.id non trouvé pour cette opération', [
'ope_users_id' => $memberId,
'operation_id' => $operationId
]);
continue;
}
$stmtMember->execute([
'operation_id' => $operationId,
'user_id' => $memberId,
'user_id' => $opeUserId,
'sector_id' => $sectorId,
'user_creat' => $userId
]);
@@ -268,16 +285,24 @@ class SectorController
$passagesCreated = 0; // Initialiser le compteur de passages
try {
$addresses = $this->addressService->getAddressesInPolygon($coordinates, $entityId);
// Enrichir les adresses avec les données bâtiments
$addresses = $this->addressService->enrichAddressesWithBuildings($addresses, $entityId);
if (!empty($addresses)) {
$queryAddress = "INSERT INTO sectors_adresses (fk_sector, fk_adresse, numero, rue, rue_bis, cp, ville, gps_lat, gps_lng)
VALUES (:sector_id, :address_id, :numero, :rue, :rue_bis, :cp, :ville, :gps_lat, :gps_lng)";
$queryAddress = "INSERT INTO sectors_adresses (
fk_sector, fk_adresse, numero, rue, rue_bis, cp, ville, gps_lat, gps_lng,
fk_batiment, fk_habitat, nb_niveau, nb_log, residence, alt_sol
) VALUES (
:sector_id, :address_id, :numero, :rue, :rue_bis, :cp, :ville, :gps_lat, :gps_lng,
:fk_batiment, :fk_habitat, :nb_niveau, :nb_log, :residence, :alt_sol
)";
$stmtAddress = $this->db->prepare($queryAddress);
foreach ($addresses as $address) {
// Extraire le rue_bis si présent (généralement vide)
$rueBis = '';
$stmtAddress->execute([
'sector_id' => $sectorId,
'address_id' => $address['id'],
@@ -287,60 +312,111 @@ class SectorController
'cp' => $address['code_postal'],
'ville' => $address['commune'],
'gps_lat' => $address['latitude'],
'gps_lng' => $address['longitude']
'gps_lng' => $address['longitude'],
'fk_batiment' => $address['fk_batiment'] ?? null,
'fk_habitat' => $address['fk_habitat'] ?? 1,
'nb_niveau' => $address['nb_niveau'] ?? null,
'nb_log' => $address['nb_log'] ?? null,
'residence' => $address['residence'] ?? '',
'alt_sol' => $address['alt_sol'] ?? null
]);
}
// Créer les passages pour chaque adresse
if (!empty($users)) {
$firstUserId = $users[0]; // Premier user pour l'affectation des passages
$passageQuery = "INSERT INTO ope_pass (
fk_operation, fk_sector, fk_user, fk_adresse,
numero, rue, rue_bis, ville,
gps_lat, gps_lng, fk_type, encrypted_name,
created_at, fk_user_creat, chk_active
) VALUES (
:operation_id, :sector_id, :user_id, :fk_adresse,
:numero, :rue, :rue_bis, :ville,
:gps_lat, :gps_lng, 2, '',
NOW(), :user_creat, 1
)";
$passageStmt = $this->db->prepare($passageQuery);
$passagesCreated = 0;
foreach ($addresses as $address) {
// Vérifier si cette adresse n'est pas déjà utilisée par un passage orphelin
if (in_array($address['id'], $addressesToExclude)) {
continue; // Passer à l'adresse suivante
}
try {
// Extraire le rue_bis si présent (généralement vide)
$rueBis = '';
$passageStmt->execute([
'operation_id' => $operationId,
'sector_id' => $sectorId,
'user_id' => $firstUserId,
'fk_adresse' => $address['id'],
'numero' => $address['numero'],
'rue' => $address['voie'],
'rue_bis' => $rueBis,
'ville' => $address['commune'],
'gps_lat' => $address['latitude'],
'gps_lng' => $address['longitude'],
'user_creat' => $userId
]);
$passagesCreated++;
} catch (\Exception $e) {
$this->logService->warning('Erreur lors de la création d\'un passage', [
'address_id' => $address['id'],
'error' => $e->getMessage()
]);
// Récupérer ope_users.id pour le premier utilisateur
// $users[0] est DÉJÀ ope_users.id (envoyé par Flutter)
$stmtFirstOpeUser = $this->db->prepare('
SELECT id FROM ope_users
WHERE id = ? AND fk_operation = ?
');
$stmtFirstOpeUser->execute([$users[0], $operationId]);
$firstOpeUserId = $stmtFirstOpeUser->fetchColumn();
if (!$firstOpeUserId) {
$this->logService->warning('Premier ope_users.id non trouvé pour cette opération', [
'ope_users_id' => $users[0],
'operation_id' => $operationId
]);
// Pas de création de passages sans utilisateur valide dans ope_users
} else {
$passageQuery = "INSERT INTO ope_pass (
fk_operation, fk_sector, fk_user, fk_adresse,
numero, rue, rue_bis, ville, residence, appt, fk_habitat,
gps_lat, gps_lng, fk_type, nb_passages, encrypted_name,
created_at, fk_user_creat, chk_active
) VALUES (
:operation_id, :sector_id, :user_id, :fk_adresse,
:numero, :rue, :rue_bis, :ville, :residence, :appt, :fk_habitat,
:gps_lat, :gps_lng, 2, 0, '',
NOW(), :user_creat, 1
)";
$passageStmt = $this->db->prepare($passageQuery);
$passagesCreated = 0;
foreach ($addresses as $address) {
// Vérifier si cette adresse n'est pas déjà utilisée par un passage orphelin
if (in_array($address['id'], $addressesToExclude)) {
continue; // Passer à l'adresse suivante
}
try {
// Extraire le rue_bis si présent (généralement vide)
$rueBis = '';
// Déterminer le nombre de passages à créer
$fkHabitat = $address['fk_habitat'] ?? 1;
$nbLog = ($fkHabitat == 2 && isset($address['nb_log'])) ? (int)$address['nb_log'] : 1;
$residence = $address['residence'] ?? '';
// IMPORTANT : Uniformisation GPS pour les immeubles
// Tous les passages d'une même adresse partagent les mêmes coordonnées GPS
// Issues de la table adresses enrichie (gps_lat, gps_lng)
$gpsLat = $address['latitude'];
$gpsLng = $address['longitude'];
// Créer 1 passage pour maison individuelle, nb_log passages pour immeuble
for ($i = 1; $i <= $nbLog; $i++) {
$appt = ($fkHabitat == 2) ? (string)$i : ''; // Numéro d'appartement pour immeubles
$passageStmt->execute([
'operation_id' => $operationId,
'sector_id' => $sectorId,
'user_id' => $firstOpeUserId,
'fk_adresse' => $address['id'],
'numero' => $address['numero'],
'rue' => $address['voie'],
'rue_bis' => $rueBis,
'ville' => $address['commune'],
'residence' => $residence,
'appt' => $appt,
'fk_habitat' => $fkHabitat,
'gps_lat' => $gpsLat,
'gps_lng' => $gpsLng,
'user_creat' => $userId
]);
$passagesCreated++;
}
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
if ($fkHabitat == 2 && $nbLog > 1) {
$this->logService->info('[SectorController] Création passages immeuble avec GPS uniformisés', [
'address_id' => $address['id'],
'nb_passages' => $nbLog,
'gps_lat' => $gpsLat,
'gps_lng' => $gpsLng,
'residence' => $residence
]);
}
} catch (\Exception $e) {
$this->logService->warning('Erreur lors de la création d\'un passage', [
'address_id' => $address['id'],
'error' => $e->getMessage()
]);
}
}
}
}
}
} catch (\Exception $e) {
@@ -351,9 +427,16 @@ class SectorController
'entity_id' => $entityId
]);
}
$this->db->commit();
// Log de création du secteur
EventLogService::logSectorCreated(
(int)$sectorId,
(int)$operationId,
$sectorData['libelle']
);
// Préparer les données de réponse
$responseData = [
'sector_id' => $sectorId
@@ -413,9 +496,10 @@ class SectorController
}
// Récupérer les users affectés
$usersQuery = "SELECT u.id, u.first_name, u.sect_name, u.encrypted_name, ous.fk_sector
$usersQuery = "SELECT u.id, ou.id as ope_user_id, u.first_name, u.sect_name, u.encrypted_name, ous.fk_sector
FROM ope_users_sectors ous
JOIN users u ON ous.fk_user = u.id
JOIN ope_users ou ON ous.fk_user = ou.id
JOIN users u ON ou.fk_user = u.id
WHERE ous.fk_sector = :sector_id";
$usersStmt = $this->db->prepare($usersQuery);
$usersStmt->execute(['sector_id' => $sectorId]);
@@ -425,7 +509,8 @@ class SectorController
$responseData['users_sectors'] = [];
foreach ($usersSectors as $userSector) {
$userData = [
'id' => $userSector['id'],
'user_id' => $userSector['id'],
'ope_user_id' => $userSector['ope_user_id'],
'first_name' => $userSector['first_name'] ?? '',
'sect_name' => $userSector['sect_name'] ?? '',
'fk_sector' => $userSector['fk_sector'],
@@ -498,24 +583,27 @@ class SectorController
try {
$data = json_decode(file_get_contents('php://input'), true);
$entityId = $_SESSION['entity_id'] ?? null;
if (!$entityId) {
Response::json(['status' => 'error', 'message' => 'Entité non définie'], 400);
return;
}
// Vérifier que le secteur appartient à l'entité
$checkQuery = "SELECT s.id
$checkQuery = "SELECT s.id, s.fk_operation, s.libelle
FROM ope_sectors s
JOIN operations o ON s.fk_operation = o.id
WHERE s.id = :id AND o.fk_entite = :entity_id";
$checkStmt = $this->db->prepare($checkQuery);
$checkStmt->execute(['id' => $id, 'entity_id' => $entityId]);
if (!$checkStmt->fetch()) {
$existingSector = $checkStmt->fetch();
if (!$existingSector) {
Response::json(['status' => 'error', 'message' => 'Secteur non trouvé'], 404);
return;
}
$operationId = $existingSector['fk_operation'];
$this->db->beginTransaction();
@@ -580,8 +668,8 @@ class SectorController
// Ajouter les nouvelles affectations
if (!empty($data['users'])) {
$insertQuery = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, fk_user_creat, chk_active)
VALUES (:operation_id, :user_id, :sector_id, NOW(), :user_creat, 1)";
$insertQuery = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, updated_at, fk_user_creat, chk_active)
VALUES (:operation_id, :user_id, :sector_id, NOW(), NOW(), :user_creat, 1)";
$this->logService->info('[UPDATE USERS] SQL - Requête INSERT préparée', [
'query' => $insertQuery
]);
@@ -591,9 +679,27 @@ class SectorController
$failedUsers = [];
foreach ($data['users'] as $memberId) {
try {
// $memberId est DÉJÀ ope_users.id (envoyé par Flutter)
// Vérifier que cet ope_users.id existe et appartient bien à l'opération
$stmtOpeUser = $this->db->prepare('
SELECT id FROM ope_users
WHERE id = ? AND fk_operation = ?
');
$stmtOpeUser->execute([$memberId, $operationId]);
$opeUserId = $stmtOpeUser->fetchColumn();
if (!$opeUserId) {
$this->logService->warning('[UPDATE USERS] ope_users.id non trouvé pour cette opération', [
'ope_users_id' => $memberId,
'operation_id' => $operationId
]);
$failedUsers[] = $memberId;
continue;
}
$params = [
'operation_id' => $operationId,
'user_id' => $memberId,
'user_id' => $opeUserId,
'sector_id' => $id,
'user_creat' => $_SESSION['user_id'] ?? null
];
@@ -626,14 +732,25 @@ class SectorController
}
}
// Gérer les passages si le secteur a changé
// Gérer les passages si le secteur a changé ET si chk_adresses_change = 1
$passageCounters = [
'passages_orphaned' => 0,
'passages_updated' => 0,
'passages_created' => 0,
'passages_kept' => 0
];
if (isset($data['sector'])) {
// chk_adresses_change : 0=ne pas toucher aux adresses/passages, 1=recalculer (défaut)
$chkAdressesChange = $data['chk_adresses_change'] ?? 1;
if (isset($data['sector']) && $chkAdressesChange == 0) {
$this->logService->info('[UPDATE] Modification secteur sans recalcul adresses/passages', [
'sector_id' => $id,
'chk_adresses_change' => $chkAdressesChange
]);
}
if (isset($data['sector']) && $chkAdressesChange == 1) {
// Mettre à jour les adresses du secteur AVANT de traiter les passages
try {
// Supprimer les anciennes adresses
@@ -660,17 +777,25 @@ class SectorController
]);
$addresses = $this->addressService->getAddressesInPolygon($coordinates, $entityId);
// Enrichir les adresses avec les données bâtiments
$addresses = $this->addressService->enrichAddressesWithBuildings($addresses, $entityId);
$this->logService->info('[UPDATE] Adresses récupérées', [
'sector_id' => $id,
'nb_addresses' => count($addresses)
]);
if (!empty($addresses)) {
$queryAddress = "INSERT INTO sectors_adresses (fk_sector, fk_adresse, numero, rue, cp, ville, gps_lat, gps_lng)
VALUES (:sector_id, :address_id, :numero, :rue, :cp, :ville, :gps_lat, :gps_lng)";
$queryAddress = "INSERT INTO sectors_adresses (
fk_sector, fk_adresse, numero, rue, cp, ville, gps_lat, gps_lng,
fk_batiment, fk_habitat, nb_niveau, nb_log, residence, alt_sol
) VALUES (
:sector_id, :address_id, :numero, :rue, :cp, :ville, :gps_lat, :gps_lng,
:fk_batiment, :fk_habitat, :nb_niveau, :nb_log, :residence, :alt_sol
)";
$stmtAddress = $this->db->prepare($queryAddress);
foreach ($addresses as $address) {
$stmtAddress->execute([
'sector_id' => $id,
@@ -680,7 +805,13 @@ class SectorController
'cp' => $address['code_postal'],
'ville' => $address['commune'],
'gps_lat' => $address['latitude'],
'gps_lng' => $address['longitude']
'gps_lng' => $address['longitude'],
'fk_batiment' => $address['fk_batiment'] ?? null,
'fk_habitat' => $address['fk_habitat'] ?? 1,
'nb_niveau' => $address['nb_niveau'] ?? null,
'nb_log' => $address['nb_log'] ?? null,
'residence' => $address['residence'] ?? '',
'alt_sol' => $address['alt_sol'] ?? null
]);
}
@@ -715,10 +846,29 @@ class SectorController
// Commit des modifications (users et/ou secteur)
$this->db->commit();
// Log de mise à jour du secteur
$changes = [];
if (isset($data['libelle'])) {
$changes['libelle'] = ['new' => $data['libelle']];
}
if (isset($data['color'])) {
$changes['color'] = ['new' => $data['color']];
}
if (isset($data['sector'])) {
$changes['sector'] = true; // Polygon modifié
}
if (isset($data['users'])) {
$changes['users'] = true; // Affectation modifiée
}
if (!empty($changes)) {
EventLogService::logSectorUpdated((int)$id, (int)$operationId, $changes);
}
// Récupérer le secteur mis à jour
$query = "
SELECT
SELECT
s.id,
s.libelle,
s.color,
@@ -726,57 +876,61 @@ class SectorController
FROM ope_sectors s
WHERE s.id = :id
";
$stmt = $this->db->prepare($query);
$stmt->execute(['id' => $id]);
$sector = $stmt->fetch(\PDO::FETCH_ASSOC);
// Récupérer tous les passages du secteur
$passagesQuery = "SELECT id, fk_operation, fk_sector, fk_user, fk_type, fk_adresse, passed_at,
numero, rue, rue_bis, ville, residence, fk_habitat, appt, niveau,
gps_lat, gps_lng, nom_recu, encrypted_name, remarque, encrypted_email,
encrypted_phone, montant, fk_type_reglement, email_erreur, nb_passages
FROM ope_pass
WHERE fk_sector = :sector_id
ORDER BY id";
$passagesStmt = $this->db->prepare($passagesQuery);
$passagesStmt->execute(['sector_id' => $id]);
$passages = $passagesStmt->fetchAll(\PDO::FETCH_ASSOC);
// Déchiffrer les données sensibles des passages
// Récupérer les passages UNIQUEMENT si chk_adresses_change = 1
$passagesDecrypted = [];
foreach ($passages as $passage) {
// Déchiffrement du nom
$passage['name'] = '';
if (!empty($passage['encrypted_name'])) {
$passage['name'] = ApiService::decryptData($passage['encrypted_name']);
}
unset($passage['encrypted_name']);
// Déchiffrement de l'email
$passage['email'] = '';
if (!empty($passage['encrypted_email'])) {
$decryptedEmail = ApiService::decryptSearchableData($passage['encrypted_email']);
if ($decryptedEmail) {
$passage['email'] = $decryptedEmail;
if ($chkAdressesChange == 1) {
// Récupérer tous les passages du secteur
$passagesQuery = "SELECT id, fk_operation, fk_sector, fk_user, fk_type, fk_adresse, passed_at,
numero, rue, rue_bis, ville, residence, fk_habitat, appt, niveau,
gps_lat, gps_lng, nom_recu, encrypted_name, remarque, encrypted_email,
encrypted_phone, montant, fk_type_reglement, email_erreur, nb_passages
FROM ope_pass
WHERE fk_sector = :sector_id
ORDER BY id";
$passagesStmt = $this->db->prepare($passagesQuery);
$passagesStmt->execute(['sector_id' => $id]);
$passages = $passagesStmt->fetchAll(\PDO::FETCH_ASSOC);
// Déchiffrer les données sensibles des passages
foreach ($passages as $passage) {
// Déchiffrement du nom
$passage['name'] = '';
if (!empty($passage['encrypted_name'])) {
$passage['name'] = ApiService::decryptData($passage['encrypted_name']);
}
unset($passage['encrypted_name']);
// Déchiffrement de l'email
$passage['email'] = '';
if (!empty($passage['encrypted_email'])) {
$decryptedEmail = ApiService::decryptSearchableData($passage['encrypted_email']);
if ($decryptedEmail) {
$passage['email'] = $decryptedEmail;
}
}
unset($passage['encrypted_email']);
// Déchiffrement du téléphone
$passage['phone'] = '';
if (!empty($passage['encrypted_phone'])) {
$passage['phone'] = ApiService::decryptData($passage['encrypted_phone']);
}
unset($passage['encrypted_phone']);
$passagesDecrypted[] = $passage;
}
unset($passage['encrypted_email']);
// Déchiffrement du téléphone
$passage['phone'] = '';
if (!empty($passage['encrypted_phone'])) {
$passage['phone'] = ApiService::decryptData($passage['encrypted_phone']);
}
unset($passage['encrypted_phone']);
$passagesDecrypted[] = $passage;
}
// Récupérer les users affectés (avec READ UNCOMMITTED pour forcer la lecture des données fraîches)
$usersQuery = "SELECT u.id, u.first_name, u.sect_name, u.encrypted_name, ous.fk_sector
$usersQuery = "SELECT u.id, ou.id as ope_user_id, u.first_name, u.sect_name, u.encrypted_name, ous.fk_sector
FROM ope_users_sectors ous
JOIN users u ON ous.fk_user = u.id
JOIN ope_users ou ON ous.fk_user = ou.id
JOIN users u ON ou.fk_user = u.id
WHERE ous.fk_sector = :sector_id
ORDER BY u.id";
@@ -801,7 +955,8 @@ class SectorController
$usersDecrypted = [];
foreach ($usersSectors as $userSector) {
$userData = [
'id' => $userSector['id'],
'user_id' => $userSector['id'],
'ope_user_id' => $userSector['ope_user_id'],
'first_name' => $userSector['first_name'] ?? '',
'sect_name' => $userSector['sect_name'] ?? '',
'fk_sector' => $userSector['fk_sector'],
@@ -934,18 +1089,20 @@ class SectorController
}
// Vérifier que le secteur existe et récupérer ses informations
$checkQuery = "SELECT s.id, s.libelle, o.fk_entite
$checkQuery = "SELECT s.id, s.libelle, s.fk_operation, o.fk_entite
FROM ope_sectors s
JOIN operations o ON s.fk_operation = o.id
WHERE s.id = :id";
$checkStmt = $this->db->prepare($checkQuery);
$checkStmt->execute(['id' => $id]);
$sector = $checkStmt->fetch();
if (!$sector || $sector['fk_entite'] != $entityId) {
Response::json(['status' => 'error', 'message' => 'Secteur non trouvé ou non autorisé'], 404);
return;
}
$operationId = $sector['fk_operation'];
$this->db->beginTransaction();
@@ -1001,9 +1158,16 @@ class SectorController
$deleteQuery = "DELETE FROM ope_sectors WHERE id = :id";
$deleteStmt = $this->db->prepare($deleteQuery);
$deleteStmt->execute(['id' => $id]);
$this->db->commit();
// Log de suppression du secteur (suppression physique = false)
EventLogService::logSectorDeleted(
(int)$id,
(int)$operationId,
false // suppression physique (DELETE)
);
// Déchiffrer les données sensibles des passages
$passagesDecrypted = [];
foreach ($passagesToUpdate as $passage) {
@@ -1249,8 +1413,11 @@ class SectorController
}
// 2. CRÉATION/MISE À JOUR DES PASSAGES POUR LES NOUVELLES ADRESSES (OPTIMISÉE)
// Récupérer toutes les adresses du secteur depuis sectors_adresses
$addressesQuery = "SELECT * FROM sectors_adresses WHERE fk_sector = :sector_id";
// Récupérer toutes les adresses du secteur depuis sectors_adresses (avec colonnes bâtiments)
$addressesQuery = "SELECT
fk_sector, fk_adresse, numero, rue, rue_bis, cp, ville, gps_lat, gps_lng,
fk_batiment, fk_habitat, nb_niveau, nb_log, residence, alt_sol
FROM sectors_adresses WHERE fk_sector = :sector_id";
$addressesStmt = $this->db->prepare($addressesQuery);
$addressesStmt->execute(['sector_id' => $sectorId]);
$addresses = $addressesStmt->fetchAll();
@@ -1268,93 +1435,121 @@ class SectorController
$firstUserId = $firstUser ? $firstUser['fk_user'] : null;
if ($firstUserId && !empty($addresses)) {
$this->logService->info('[updatePassagesForSector] Optimisation passages', [
$this->logService->info('[updatePassagesForSector] Traitement des passages', [
'user_id' => $firstUserId,
'nb_addresses' => count($addresses)
]);
// OPTIMISATION : Récupérer TOUS les passages existants en UNE requête
$addressIds = array_filter(array_column($addresses, 'fk_adresse'));
// Construire la requête pour récupérer tous les passages existants
// Récupérer TOUS les passages existants pour cette opération en UNE requête
$existingQuery = "
SELECT id, fk_adresse, numero, rue, rue_bis, ville
SELECT id, fk_adresse, numero, rue, rue_bis, ville, residence, appt, fk_habitat,
fk_type, encrypted_name, created_at
FROM ope_pass
WHERE fk_operation = :operation_id
AND (";
$params = ['operation_id' => $operationId];
$conditions = [];
// Condition pour les fk_adresse
if (!empty($addressIds)) {
$placeholders = [];
foreach ($addressIds as $idx => $addrId) {
$key = 'addr_' . $idx;
$placeholders[] = ':' . $key;
$params[$key] = $addrId;
}
$conditions[] = "fk_adresse IN (" . implode(',', $placeholders) . ")";
}
// Condition pour les données d'adresse (numero, rue, ville)
$addressConditions = [];
foreach ($addresses as $idx => $addr) {
$numKey = 'num_' . $idx;
$rueKey = 'rue_' . $idx;
$bisKey = 'bis_' . $idx;
$villeKey = 'ville_' . $idx;
$addressConditions[] = "(numero = :$numKey AND rue = :$rueKey AND rue_bis = :$bisKey AND ville = :$villeKey)";
$params[$numKey] = $addr['numero'];
$params[$rueKey] = $addr['rue'];
$params[$bisKey] = $addr['rue_bis'];
$params[$villeKey] = $addr['ville'];
}
if (!empty($addressConditions)) {
$conditions[] = "(" . implode(' OR ', $addressConditions) . ")";
}
$existingQuery .= implode(' OR ', $conditions) . ")";
WHERE fk_operation = :operation_id";
$existingStmt = $this->db->prepare($existingQuery);
$existingStmt->execute($params);
$existingStmt->execute(['operation_id' => $operationId]);
$existingPassages = $existingStmt->fetchAll();
// Indexer les passages existants pour recherche rapide
// Indexer les passages existants par clé : numero|rue|rue_bis|ville
$passagesByAddress = [];
$passagesByData = [];
foreach ($existingPassages as $p) {
if (!empty($p['fk_adresse'])) {
$passagesByAddress[$p['fk_adresse']] = $p;
$addressKey = $p['numero'] . '|' . $p['rue'] . '|' . ($p['rue_bis'] ?? '') . '|' . $p['ville'];
if (!isset($passagesByAddress[$addressKey])) {
$passagesByAddress[$addressKey] = [];
}
$dataKey = $p['numero'] . '|' . $p['rue'] . '|' . $p['rue_bis'] . '|' . $p['ville'];
$passagesByData[$dataKey] = $p;
$passagesByAddress[$addressKey][] = $p;
}
// Préparer les listes pour batch insert/update
// Traiter chaque adresse du secteur
$toInsert = [];
$toUpdate = [];
$toDelete = [];
foreach ($addresses as $address) {
// Vérification en mémoire PHP (0 requête)
if (!empty($address['fk_adresse']) && isset($passagesByAddress[$address['fk_adresse']])) {
continue; // Déjà existant avec bon fk_adresse
}
$addressKey = $address['numero'] . '|' . $address['rue'] . '|' . ($address['rue_bis'] ?? '') . '|' . $address['ville'];
$existingAtAddress = $passagesByAddress[$addressKey] ?? [];
$nbExisting = count($existingAtAddress);
$dataKey = $address['numero'] . '|' . $address['rue'] . '|' . $address['rue_bis'] . '|' . $address['ville'];
if (isset($passagesByData[$dataKey])) {
// Passage existant mais sans fk_adresse ou avec fk_adresse différent
if (!empty($address['fk_adresse']) && $passagesByData[$dataKey]['fk_adresse'] != $address['fk_adresse']) {
$toUpdate[] = [
'id' => $passagesByData[$dataKey]['id'],
'fk_adresse' => $address['fk_adresse']
$fkHabitat = $address['fk_habitat'] ?? 1;
$nbLog = ($fkHabitat == 2 && isset($address['nb_log'])) ? (int)$address['nb_log'] : 1;
$residence = $address['residence'] ?? '';
// IMPORTANT : Uniformisation GPS pour les immeubles
// Tous les passages d'une même adresse doivent partager les mêmes coordonnées GPS
// Issues de sectors_adresses (gps_lat, gps_lng)
$gpsLat = $address['gps_lat'];
$gpsLng = $address['gps_lng'];
// CAS 1 : Maison individuelle (fk_habitat=1)
if ($fkHabitat == 1) {
if ($nbExisting == 0) {
// INSERT 1 passage
$toInsert[] = [
'address' => $address,
'residence' => '',
'appt' => '',
'fk_habitat' => 1
];
} else {
// UPDATE le premier passage avec fk_habitat=1
$toUpdate[] = [
'id' => $existingAtAddress[0]['id'],
'fk_habitat' => 1,
'residence' => '',
'gps_lat' => $gpsLat,
'gps_lng' => $gpsLng
];
// Les autres passages (si >1) ne sont PAS touchés
}
}
// CAS 2 : Immeuble (fk_habitat=2)
else if ($fkHabitat == 2) {
// UPDATE TOUS les passages existants avec fk_habitat=2, residence et GPS
foreach ($existingAtAddress as $existing) {
$updates = [
'id' => $existing['id'],
'fk_habitat' => 2,
'gps_lat' => $gpsLat,
'gps_lng' => $gpsLng
];
// Update residence seulement si non vide
if (!empty($residence)) {
$updates['residence'] = $residence;
}
$toUpdate[] = $updates;
}
// Si moins de nb_log passages : INSERT les manquants
if ($nbExisting < $nbLog) {
$nbToInsert = $nbLog - $nbExisting;
for ($i = 0; $i < $nbToInsert; $i++) {
$toInsert[] = [
'address' => $address,
'residence' => $residence,
'appt' => '', // Pas de numéro d'appt prédéfini
'fk_habitat' => 2
];
}
}
// Si plus de nb_log passages : DELETE les non visités en trop
else if ($nbExisting > $nbLog) {
$nbToDelete = $nbExisting - $nbLog;
// Trier les passages par created_at ASC (les plus anciens d'abord)
usort($existingAtAddress, function($a, $b) {
return strtotime($a['created_at']) - strtotime($b['created_at']);
});
$deleted = 0;
foreach ($existingAtAddress as $existing) {
if ($deleted >= $nbToDelete) break;
// Supprimer seulement si fk_type=2 ET encrypted_name vide
if ($existing['fk_type'] == 2 && ($existing['encrypted_name'] === '' || $existing['encrypted_name'] === null)) {
$toDelete[] = $existing['id'];
$deleted++;
}
}
}
} else {
// Nouveau passage à créer
$toInsert[] = $address;
}
}
@@ -1364,19 +1559,24 @@ class SectorController
$insertParams = [];
$paramIndex = 0;
foreach ($toInsert as $addr) {
foreach ($toInsert as $item) {
$addr = $item['address'];
$values[] = "(:op$paramIndex, :sect$paramIndex, :usr$paramIndex, :addr$paramIndex,
:num$paramIndex, :rue$paramIndex, :bis$paramIndex, :ville$paramIndex,
:lat$paramIndex, :lng$paramIndex, 2, '', NOW(), :creat$paramIndex, 1)";
:res$paramIndex, :appt$paramIndex, :habitat$paramIndex,
:lat$paramIndex, :lng$paramIndex, 2, 0, '', NOW(), :creat$paramIndex, 1)";
$insertParams["op$paramIndex"] = $operationId;
$insertParams["sect$paramIndex"] = $sectorId;
$insertParams["usr$paramIndex"] = $firstUserId;
$insertParams["addr$paramIndex"] = $addr['fk_adresse'];
$insertParams["addr$paramIndex"] = $addr['fk_adresse'] ?? null;
$insertParams["num$paramIndex"] = $addr['numero'];
$insertParams["rue$paramIndex"] = $addr['rue'];
$insertParams["bis$paramIndex"] = $addr['rue_bis'];
$insertParams["bis$paramIndex"] = $addr['rue_bis'] ?? '';
$insertParams["ville$paramIndex"] = $addr['ville'];
$insertParams["res$paramIndex"] = $item['residence'];
$insertParams["appt$paramIndex"] = $item['appt'];
$insertParams["habitat$paramIndex"] = $item['fk_habitat'];
$insertParams["lat$paramIndex"] = $addr['gps_lat'];
$insertParams["lng$paramIndex"] = $addr['gps_lng'];
$insertParams["creat$paramIndex"] = $_SESSION['user_id'] ?? null;
@@ -1386,7 +1586,7 @@ class SectorController
$insertQuery = "INSERT INTO ope_pass
(fk_operation, fk_sector, fk_user, fk_adresse, numero, rue, rue_bis,
ville, gps_lat, gps_lng, fk_type, encrypted_name, created_at, fk_user_creat, chk_active)
ville, residence, appt, fk_habitat, gps_lat, gps_lng, fk_type, nb_passages, encrypted_name, created_at, fk_user_creat, chk_active)
VALUES " . implode(',', $values);
try {
@@ -1401,28 +1601,67 @@ class SectorController
}
}
// UPDATE MULTIPLE avec CASE WHEN
// UPDATE MULTIPLE avec CASE WHEN (inclut GPS pour uniformisation)
if (!empty($toUpdate)) {
$updateIds = array_column($toUpdate, 'id');
$placeholders = str_repeat('?,', count($updateIds) - 1) . '?';
$caseWhen = [];
$caseWhenHabitat = [];
$caseWhenResidence = [];
$caseWhenGpsLat = [];
$caseWhenGpsLng = [];
$updateParams = [];
foreach ($toUpdate as $upd) {
$caseWhen[] = "WHEN id = ? THEN ?";
// fk_habitat est toujours présent
$caseWhenHabitat[] = "WHEN id = ? THEN ?";
$updateParams[] = $upd['id'];
$updateParams[] = $upd['fk_adresse'];
$updateParams[] = $upd['fk_habitat'];
// GPS : toujours présent maintenant (uniformisation)
if (isset($upd['gps_lat']) && isset($upd['gps_lng'])) {
$caseWhenGpsLat[] = "WHEN id = ? THEN ?";
$updateParams[] = $upd['id'];
$updateParams[] = $upd['gps_lat'];
$caseWhenGpsLng[] = "WHEN id = ? THEN ?";
$updateParams[] = $upd['id'];
$updateParams[] = $upd['gps_lng'];
}
// residence est optionnel
if (isset($upd['residence'])) {
$caseWhenResidence[] = "WHEN id = ? THEN ?";
$updateParams[] = $upd['id'];
$updateParams[] = $upd['residence'];
}
}
$setClause = ["fk_habitat = CASE " . implode(' ', $caseWhenHabitat) . " ELSE fk_habitat END"];
if (!empty($caseWhenGpsLat)) {
$setClause[] = "gps_lat = CASE " . implode(' ', $caseWhenGpsLat) . " ELSE gps_lat END";
}
if (!empty($caseWhenGpsLng)) {
$setClause[] = "gps_lng = CASE " . implode(' ', $caseWhenGpsLng) . " ELSE gps_lng END";
}
if (!empty($caseWhenResidence)) {
$setClause[] = "residence = CASE " . implode(' ', $caseWhenResidence) . " ELSE residence END";
}
$updateQuery = "UPDATE ope_pass
SET fk_adresse = CASE " . implode(' ', $caseWhen) . " END
SET " . implode(', ', $setClause) . "
WHERE id IN ($placeholders)";
try {
$updateStmt = $this->db->prepare($updateQuery);
$updateStmt->execute(array_merge($updateParams, $updateIds));
$counters['passages_updated'] = count($toUpdate);
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
$this->logService->info('[updatePassagesForSector] Passages mis à jour avec GPS uniformisés', [
'nb_updated' => count($toUpdate),
'sector_id' => $sectorId
]);
} catch (\Exception $e) {
$this->logService->error('Erreur lors de la mise à jour multiple des passages', [
'sector_id' => $sectorId,
@@ -1431,6 +1670,23 @@ class SectorController
}
}
// DELETE MULTIPLE en une seule requête
if (!empty($toDelete)) {
$placeholders = str_repeat('?,', count($toDelete) - 1) . '?';
$deleteQuery = "DELETE FROM ope_pass WHERE id IN ($placeholders)";
try {
$deleteStmt = $this->db->prepare($deleteQuery);
$deleteStmt->execute($toDelete);
$counters['passages_deleted'] += count($toDelete);
} catch (\Exception $e) {
$this->logService->error('Erreur lors de la suppression multiple des passages', [
'sector_id' => $sectorId,
'error' => $e->getMessage()
]);
}
}
} else {
$this->logService->warning('[updatePassagesForSector] Pas de création de passages', [
'reason' => !$firstUserId ? 'Pas d\'utilisateur affecté' : 'Pas d\'adresses',

View File

@@ -6,6 +6,9 @@ namespace App\Controllers;
use App\Core\Controller;
use App\Services\StripeService;
use App\Services\LogService;
use App\Services\FileService;
use App\Services\ApiService;
use Session;
use Exception;
@@ -77,7 +80,7 @@ class StripeController extends Controller {
$this->requireAuth();
// Log du début de la requête
\LogService::log('Début createOnboardingLink', [
LogService::log('Début createOnboardingLink', [
'account_id' => $accountId,
'user_id' => Session::getUserId()
]);
@@ -98,7 +101,7 @@ class StripeController extends Controller {
$returnUrl = $data['return_url'] ?? '';
$refreshUrl = $data['refresh_url'] ?? '';
\LogService::log('URLs reçues', [
LogService::log('URLs reçues', [
'return_url' => $returnUrl,
'refresh_url' => $refreshUrl
]);
@@ -110,7 +113,7 @@ class StripeController extends Controller {
$result = $this->stripeService->createOnboardingLink($accountId, $returnUrl, $refreshUrl);
\LogService::log('Résultat createOnboardingLink', [
LogService::log('Résultat createOnboardingLink', [
'success' => $result['success'] ?? false,
'has_url' => isset($result['url'])
]);
@@ -127,7 +130,7 @@ class StripeController extends Controller {
}
} catch (Exception $e) {
\LogService::log('Erreur createOnboardingLink', [
LogService::log('Erreur createOnboardingLink', [
'level' => 'error',
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString()
@@ -190,7 +193,7 @@ class StripeController extends Controller {
// Vérifier que le passage existe et appartient à l'utilisateur
$stmt = $this->db->prepare('
SELECT p.*, o.fk_entite
SELECT p.*, o.fk_entite, o.id as operation_id
FROM ope_pass p
JOIN operations o ON p.fk_operation = o.id
WHERE p.id = ? AND p.fk_user = ?
@@ -210,13 +213,15 @@ class StripeController extends Controller {
}
// Vérifier que le montant correspond (passage.montant est en euros, amount en centimes)
$expectedAmount = (int)($passage['montant'] * 100);
$expectedAmount = (int)round($passage['montant'] * 100);
if ($amount !== $expectedAmount) {
$this->sendError("Le montant ne correspond pas au passage (attendu: {$expectedAmount} centimes, reçu: {$amount} centimes)", 400);
return;
}
$entiteId = $passage['fk_entite'];
$operationId = $passage['operation_id'];
$fkUser = $passage['fk_user']; // ope_users.id
// Déterminer le type de paiement (Tap to Pay ou Web)
$paymentMethodTypes = $data['payment_method_types'] ?? ['card_present'];
@@ -230,14 +235,16 @@ class StripeController extends Controller {
'payment_method_types' => $paymentMethodTypes,
'capture_method' => $data['capture_method'] ?? 'automatic',
'passage_id' => $passageId,
'amicale_id' => $data['amicale_id'] ?? $entiteId,
'member_id' => $data['member_id'] ?? Session::getUserId(),
'fk_entite' => $data['amicale_id'] ?? $entiteId,
'fk_user' => $data['member_id'] ?? $fkUser,
'stripe_account' => $data['stripe_account'] ?? null,
'metadata' => array_merge(
[
'passage_id' => (string)$passageId,
'operation_id' => (string)$operationId,
'amicale_id' => (string)($data['amicale_id'] ?? $entiteId),
'member_id' => (string)($data['member_id'] ?? Session::getUserId()),
'fk_user' => (string)$fkUser,
'created_at' => (string)time(),
'type' => $isTapToPay ? 'tap_to_pay' : 'web'
],
$data['metadata'] ?? []
@@ -291,11 +298,12 @@ class StripeController extends Controller {
$stmt = $this->db->prepare("
SELECT p.*, o.fk_entite,
e.encrypted_name as entite_nom,
u.first_name as user_prenom, u.sect_name as user_nom
ou.first_name as user_prenom, u.sect_name as user_nom
FROM ope_pass p
JOIN operations o ON p.fk_operation = o.id
LEFT JOIN entites e ON o.fk_entite = e.id
LEFT JOIN users u ON p.fk_user = u.id
LEFT JOIN ope_users ou ON p.fk_user = ou.id
LEFT JOIN users u ON ou.fk_user = u.id
WHERE p.stripe_payment_id = :pi_id
");
$stmt->execute(['pi_id' => $paymentIntentId]);
@@ -330,7 +338,7 @@ class StripeController extends Controller {
$entiteNom = '';
if (!empty($passage['entite_nom'])) {
try {
$entiteNom = \ApiService::decryptData($passage['entite_nom']);
$entiteNom = ApiService::decryptData($passage['entite_nom']);
} catch (Exception $e) {
$entiteNom = 'Entité inconnue';
}
@@ -400,6 +408,7 @@ class StripeController extends Controller {
$this->sendSuccess([
'has_account' => false,
'account_id' => null,
'location_id' => null,
'charges_enabled' => false,
'payouts_enabled' => false,
'onboarding_completed' => false
@@ -415,6 +424,7 @@ class StripeController extends Controller {
$this->sendSuccess([
'has_account' => true,
'account_id' => $account['stripe_account_id'],
'location_id' => $account['stripe_location_id'] ?? null,
'charges_enabled' => false,
'payouts_enabled' => false,
'onboarding_completed' => false,
@@ -440,6 +450,7 @@ class StripeController extends Controller {
$this->sendSuccess([
'has_account' => true,
'account_id' => $account['stripe_account_id'],
'location_id' => $account['stripe_location_id'] ?? null,
'charges_enabled' => $stripeAccount->charges_enabled,
'payouts_enabled' => $stripeAccount->payouts_enabled,
'onboarding_completed' => $stripeAccount->details_submitted,
@@ -529,17 +540,17 @@ class StripeController extends Controller {
public function getPublicConfig(): void {
try {
$this->requireAuth();
$this->sendSuccess([
'public_key' => $this->stripeService->getPublicKey(),
'test_mode' => $this->stripeService->isTestMode()
]);
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* GET /api/stripe/stats
* Récupérer les statistiques de paiement
@@ -613,9 +624,164 @@ class StripeController extends Controller {
'to' => $dateTo
]
]);
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/payment-links
* Créer un Payment Link Stripe pour paiement par QR Code
*
* Payload:
* {
* "amount": 2500,
* "currency": "eur",
* "description": "Calendrier pompiers",
* "passage_id": 789,
* "metadata": {...}
* }
*/
public function createPaymentLink(): void {
try {
$this->requireAuth();
$data = $this->getJsonInput();
// Validation
if (!isset($data['amount']) || !isset($data['passage_id'])) {
$this->sendError('Montant et passage_id requis', 400);
return;
}
$amount = (int)$data['amount'];
$passageId = (int)$data['passage_id'];
// Validation du montant (doit être > 0)
if ($amount <= 0) {
$this->sendError('Le montant doit être supérieur à 0', 400);
return;
}
// Vérifier que le passage appartient à l'utilisateur ou à son entité
$userId = Session::getUserId();
$stmt = $this->db->prepare('
SELECT p.*, o.fk_entite, ou.fk_user as ope_user_id
FROM ope_pass p
JOIN operations o ON p.fk_operation = o.id
JOIN ope_users ou ON p.fk_user = ou.id
WHERE p.id = ?
');
$stmt->execute([$passageId]);
$passage = $stmt->fetch();
if (!$passage) {
$this->sendError('Passage non trouvé', 404);
return;
}
// Vérifier les droits : soit l'utilisateur est le créateur du passage, soit il appartient à la même entité
$userEntityId = Session::getEntityId();
if ($passage['ope_user_id'] != $userId && $passage['fk_entite'] != $userEntityId) {
$this->sendError('Passage non autorisé', 403);
return;
}
// Vérifier qu'il n'y a pas déjà un paiement ou un payment link pour ce passage
if (!empty($passage['stripe_payment_id'])) {
$this->sendError('Un paiement Stripe existe déjà pour ce passage', 400);
return;
}
if (!empty($passage['stripe_payment_link_id'])) {
$this->sendError('Un Payment Link existe déjà pour ce passage', 400);
return;
}
// Vérifier que le montant correspond (passage.montant est en euros, amount en centimes)
$expectedAmount = (int)round($passage['montant'] * 100);
if ($amount !== $expectedAmount) {
$this->sendError("Le montant ne correspond pas au passage (attendu: {$expectedAmount} centimes, reçu: {$amount} centimes)", 400);
return;
}
// Préparer les paramètres
$params = [
'amount' => $amount,
'currency' => $data['currency'] ?? 'eur',
'description' => $data['description'] ?? 'Calendrier pompiers',
'passage_id' => $passageId,
'metadata' => $data['metadata'] ?? []
];
// Créer le Payment Link
$result = $this->stripeService->createPaymentLink($params);
if ($result['success']) {
$this->sendSuccess([
'payment_link_id' => $result['payment_link_id'],
'url' => $result['url'],
'amount' => $result['amount'],
'passage_id' => $passageId,
'type' => 'qr_code'
]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/locations
* Créer une Location Stripe Terminal pour une entité (nécessaire pour Tap to Pay)
*/
public function createLocation(): void {
try {
$this->requireAuth();
// Vérifier le rôle de l'utilisateur
$userId = Session::getUserId();
$stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch();
$userRole = $result ? (int)$result['fk_role'] : 0;
if ($userRole < 2) {
$this->sendError('Droits insuffisants - Admin amicale minimum requis', 403);
return;
}
$data = $this->getJsonInput();
$entiteId = $data['fk_entite'] ?? Session::getEntityId();
if (!$entiteId) {
$this->sendError('ID entité requis', 400);
return;
}
// Vérifier les droits sur cette entité
if (Session::getEntityId() != $entiteId && $userRole < 3) {
$this->sendError('Non autorisé pour cette entité', 403);
return;
}
$result = $this->stripeService->createLocation($entiteId);
if ($result['success']) {
$this->sendSuccess([
'location_id' => $result['location_id'],
'message' => $result['message']
]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur lors de la création de la location: ' . $e->getMessage());
}
}
}

View File

@@ -43,8 +43,8 @@ class StripeWebhookController extends Controller {
}
// Récupérer le secret webhook selon le mode
$stripeConfig = $this->config->get('stripe');
$webhookSecret = $this->stripeService->isTestMode()
$stripeConfig = $this->config->getStripeConfig();
$webhookSecret = $this->stripeService->isTestMode()
? $stripeConfig['webhook_secret_test']
: $stripeConfig['webhook_secret_live'];
@@ -95,31 +95,35 @@ class StripeWebhookController extends Controller {
case 'account.updated':
$this->handleAccountUpdated($event->data->object);
break;
case 'account.application.authorized':
$this->handleAccountAuthorized($event->data->object);
break;
case 'payment_intent.succeeded':
$this->handlePaymentIntentSucceeded($event->data->object);
break;
case 'payment_intent.payment_failed':
$this->handlePaymentIntentFailed($event->data->object);
break;
case 'checkout.session.completed':
$this->handleCheckoutSessionCompleted($event->data->object);
break;
case 'charge.dispute.created':
$this->handleChargeDisputeCreated($event->data->object);
break;
case 'terminal.reader.action_succeeded':
$this->handleTerminalReaderActionSucceeded($event->data->object);
break;
case 'terminal.reader.action_failed':
$this->handleTerminalReaderActionFailed($event->data->object);
break;
default:
// Événement non géré mais valide
error_log("Unhandled Stripe event type: {$event->type}");
@@ -278,7 +282,60 @@ class StripeWebhookController extends Controller {
error_log("Payment failed: {$paymentIntent->id}, reason: " . json_encode($paymentIntent->last_payment_error));
}
/**
* Gérer la complétion d'une session de paiement (Payment Link / Checkout)
*/
private function handleCheckoutSessionCompleted($session): void {
$metadata = $session->metadata;
// Logger l'événement
error_log("Checkout session completed: {$session->id}, payment_intent: {$session->payment_intent}");
// Vérifier si un passage_id est présent dans les metadata
if (isset($metadata->passage_id) && !empty($metadata->passage_id)) {
$passageId = (int)$metadata->passage_id;
// Mettre à jour le passage avec le stripe_payment_id
$stmt = $this->db->prepare("
UPDATE ope_pass
SET stripe_payment_id = :payment_intent_id,
updated_at = NOW()
WHERE id = :passage_id
");
$stmt->execute([
'payment_intent_id' => $session->payment_intent,
'passage_id' => $passageId
]);
// Vérifier si la mise à jour a réussi
if ($stmt->rowCount() > 0) {
error_log("Passage {$passageId} updated with payment_intent {$session->payment_intent}");
// TODO: Envoyer un email de confirmation avec le reçu fiscal
// TODO: Mettre à jour les statistiques en temps réel
} else {
error_log("Warning: Passage {$passageId} not found or already updated");
}
} else {
error_log("Warning: checkout.session.completed without passage_id in metadata");
}
// Enregistrer l'historique de la session dans stripe_payment_history si nécessaire
if (isset($metadata->passage_id)) {
$stmt = $this->db->prepare("
SELECT id FROM ope_pass WHERE id = :passage_id
");
$stmt->execute(['passage_id' => $metadata->passage_id]);
$passage = $stmt->fetch();
if ($passage) {
// Log dans l'historique
error_log("Checkout session completed for passage {$metadata->passage_id}: amount={$session->amount_total}, currency={$session->currency}");
}
}
}
/**
* Gérer un litige (chargeback)
*/

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/EventLogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
require_once __DIR__ . '/../Services/PasswordSecurityService.php';
@@ -15,8 +16,9 @@ use AppConfig;
use Request;
use Response;
use Session;
use LogService;
use ApiService;
use App\Services\LogService;
use App\Services\EventLogService;
use App\Services\ApiService;
use App\Services\PasswordSecurityService;
class UserController {
@@ -529,16 +531,13 @@ class UserController {
]);
}
LogService::log('Utilisateur GeoSector créé', [
'level' => 'info',
'createdBy' => $currentUserId,
'newUserId' => $userId,
'email' => !empty($email) ? $email : 'non fourni',
'username' => $username,
'usernameManual' => $chkUsernameManuel === 1 ? 'oui' : 'non',
'passwordManual' => $chkMdpManuel === 1 ? 'oui' : 'non',
'emailsSent' => !empty($email) ? '2 emails (username + password)' : 'aucun (pas d\'email)'
]);
// Log de création utilisateur
EventLogService::logUserCreated(
(int)$userId,
(int)$entiteId,
(int)$role,
$username
);
// Préparer la réponse avec les informations de connexion si générées automatiquement
$responseData = [
@@ -762,12 +761,23 @@ class UserController {
return;
}
LogService::log('Utilisateur GeoSector mis à jour', [
'level' => 'info',
'modifiedBy' => $currentUserId,
'userId' => $id,
'fields' => array_keys($data),
]);
// Log de mise à jour utilisateur
$changes = [];
$encryptedFields = ['name', 'email', 'phone', 'mobile', 'username', 'password'];
foreach ($data as $key => $value) {
if (in_array($key, $encryptedFields)) {
// Champs sensibles : booléen uniquement
$changes['encrypted_' . $key] = true;
} else {
// Champs non sensibles : valeur
$changes[$key] = ['new' => $value];
}
}
if (!empty($changes)) {
EventLogService::logUserUpdated((int)$id, $changes);
}
Response::json([
'status' => 'success',
@@ -858,24 +868,72 @@ class UserController {
if ($transferTo) {
try {
// Transférer TOUS les passages de l'utilisateur vers l'utilisateur désigné
$stmt3 = $this->db->prepare('
UPDATE ope_pass
SET fk_user = :new_user_id
WHERE fk_user = :delete_user_id
// Transférer les passages opération par opération
// (car fk_user dans ope_pass pointe vers ope_users.id, pas users.id)
// Récupérer toutes les opérations où l'utilisateur à supprimer a des entrées dans ope_users
$stmtOps = $this->db->prepare('
SELECT DISTINCT fk_operation
FROM ope_users
WHERE fk_user = ?
');
$stmt3->execute([
'new_user_id' => $transferTo,
'delete_user_id' => $id
]);
$transferredCount = $stmt3->rowCount();
$stmtOps->execute([$id]);
$operations = $stmtOps->fetchAll(PDO::FETCH_COLUMN);
$totalTransferred = 0;
foreach ($operations as $operationId) {
// Trouver ope_users.id de l'utilisateur à supprimer dans cette opération
$stmtOldOpeUser = $this->db->prepare('
SELECT id FROM ope_users
WHERE fk_user = ? AND fk_operation = ?
');
$stmtOldOpeUser->execute([$id, $operationId]);
$oldOpeUserId = $stmtOldOpeUser->fetchColumn();
if (!$oldOpeUserId) {
continue;
}
// Trouver ope_users.id de l'utilisateur de destination dans cette opération
$stmtNewOpeUser = $this->db->prepare('
SELECT id FROM ope_users
WHERE fk_user = ? AND fk_operation = ?
');
$stmtNewOpeUser->execute([$transferTo, $operationId]);
$newOpeUserId = $stmtNewOpeUser->fetchColumn();
if (!$newOpeUserId) {
LogService::log('Impossible de transférer passages - utilisateur destination absent', [
'level' => 'warning',
'operation_id' => $operationId,
'from_user' => $id,
'to_user' => $transferTo
]);
continue;
}
// Transférer les passages
$stmtTransfer = $this->db->prepare('
UPDATE ope_pass
SET fk_user = :new_ope_user_id
WHERE fk_user = :old_ope_user_id AND fk_operation = :operation_id
');
$stmtTransfer->execute([
'new_ope_user_id' => $newOpeUserId,
'old_ope_user_id' => $oldOpeUserId,
'operation_id' => $operationId
]);
$totalTransferred += $stmtTransfer->rowCount();
}
LogService::log('Passages transférés avant suppression utilisateur', [
'level' => 'info',
'from_user' => $id,
'to_user' => $transferTo,
'passages_transferred' => $transferredCount
'operations_count' => count($operations),
'passages_transferred' => $totalTransferred
]);
} catch (PDOException $e) {
Response::json([
@@ -890,13 +948,10 @@ class UserController {
// —— Suppression réelle de l'utilisateur ——
try {
// Supprimer les enregistrements dépendants dans ope_users
// (CASCADE supprime automatiquement ope_users_sectors et ope_pass)
$stmtOpeUsers = $this->db->prepare('DELETE FROM ope_users WHERE fk_user = ?');
$stmtOpeUsers->execute([$id]);
// Supprimer les enregistrements dépendants dans ope_users_sectors
$stmtOpeUsersSectors = $this->db->prepare('DELETE FROM ope_users_sectors WHERE fk_user = ?');
$stmtOpeUsersSectors->execute([$id]);
$stmt = $this->db->prepare('DELETE FROM users WHERE id = ?');
$stmt->execute([$id]);
@@ -908,12 +963,8 @@ class UserController {
return;
}
LogService::log('Utilisateur GeoSector supprimé', [
'level' => 'info',
'deletedBy' => $currentUserId,
'userId' => $id,
'passage_transfer' => $transferTo ? "Tous les passages transférés vers utilisateur $transferTo" : 'Aucun transfert'
]);
// Log de suppression utilisateur (suppression physique = false pour soft_delete)
EventLogService::logUserDeleted((int)$id, false);
Response::json([
'status' => 'success',

View File

@@ -14,8 +14,8 @@ use AppConfig;
use Request;
use Response;
use Session;
use LogService;
use ApiService;
use App\Services\LogService;
use App\Services\ApiService;
use Exception;
class VilleController {