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

@@ -6,9 +6,9 @@ declare(strict_types=1);
* Configuration de l'application Geosector
*
* Ce fichier contient la configuration de l'application Geosector pour les trois environnements :
* - Production (app.geosector.fr)
* - Production (app3.geosector.fr)
* - Recette (rapp.geosector.fr)
* - Développement (app.geo.dev)
* - Développement (dapp.geosector.fr)
*
* Il inclut les paramètres de base de données, les informations SMTP,
* les clés de chiffrement et les configurations des services externes (Mapbox, Stripe, SMS OVH).
@@ -24,6 +24,25 @@ class AppConfig {
// Récupération du host directement depuis SERVER_NAME ou HTTP_HOST
$this->currentHost = $_SERVER['SERVER_NAME'] ?? $_SERVER['HTTP_HOST'] ?? '';
// Si on est en CLI (CRON, scripts), tenter de détecter via le marqueur d'environnement
if (empty($this->currentHost) && php_sapi_name() === 'cli') {
$markerFile = __DIR__ . '/../../.env_marker';
if (file_exists($markerFile)) {
$envMarker = trim(file_get_contents($markerFile));
switch ($envMarker) {
case 'production':
$this->currentHost = 'app3.geosector.fr';
break;
case 'recette':
$this->currentHost = 'rapp.geosector.fr';
break;
case 'development':
$this->currentHost = 'dapp.geosector.fr';
break;
}
}
}
// Récupérer les autres en-têtes pour une utilisation ultérieure si nécessaire
// getallheaders() n'existe pas en CLI, donc on vérifie
$this->headers = function_exists('getallheaders') ? getallheaders() : [];
@@ -81,10 +100,10 @@ class AppConfig {
];
// Configuration PRODUCTION
$this->config['app.geosector.fr'] = array_merge($baseConfig, [
$this->config['app3.geosector.fr'] = array_merge($baseConfig, [
'env' => 'production',
'database' => [
'host' => '13.23.33.4', // Container maria4 sur IN4
'host' => '13.23.33.4', // Container maria4 sur IN4 (51.159.7.190)
'name' => 'pra_geo',
'username' => 'pra_geo_user',
'password' => 'd2jAAGGWi8fxFrWgXjOA',
@@ -93,17 +112,23 @@ class AppConfig {
'host' => '13.23.33.4', // Container maria4 sur IN4
'name' => 'adresses',
'username' => 'adr_geo_user',
'password' => 'd66,AdrGeo.User',
'password' => 'd66,AdrGeoPrd.User',
],
'buildings_database' => [
'host' => '13.23.33.4', // Container maria4 sur IN4
'name' => 'batiments',
'username' => 'adr_geo_user',
'password' => 'd66,AdrGeoPrd.User',
],
// Configuration Stripe PRODUCTION - Clés LIVE du CLIENT
'stripe' => [
'public_key_test' => 'pk_test_XXXXXX', // Non utilisé en PROD
'secret_key_test' => 'sk_test_XXXXXX', // Non utilisé en PROD
'public_key_live' => 'CLIENT_PK_LIVE_A_REMPLACER', // ← À REMPLACER avec pk_live_...
'secret_key_live' => 'CLIENT_SK_LIVE_A_REMPLACER', // ← À REMPLACER avec sk_live_...
'public_key_live' => 'pk_live_51S5oMd1tQE0jBEomdRW82RvqAFjmqN45szbU08t8nDk4yc5QnhAJtPrP1IZJB48fF1pePUqrGsM5vyAhhoaWCT8d00nh51QIsU',
'secret_key_live' => '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',
'webhook_secret_test' => 'whsec_test_XXXXXXXXXXXX',
'webhook_secret_live' => 'whsec_live_XXXXXXXXXXXX',
'api_version' => '2024-06-20',
'webhook_secret_live' => 'whsec_gFnA6pR92RLdbAS2T6CSC18xsSdNBZHR',
'api_version' => '2025-08-27.basil',
'application_fee_percent' => 0,
'application_fee_minimum' => 0,
'mode' => 'live', // ← MODE LIVE pour la production
@@ -114,17 +139,17 @@ class AppConfig {
$this->config['rapp.geosector.fr'] = array_merge($baseConfig, [
'env' => 'recette',
'database' => [
// Configuration future avec maria3 activer après migration)
// 'host' => '13.23.33.4', // Container maria3 sur IN3
// 'name' => 'rca_geo',
// 'username' => 'rca_geo_user',
// 'password' => 'UPf3C0cQ805LypyM71iW',
// Configuration maria3 activée (migration effectuée le 16/10/2025)
'host' => '13.23.33.4', // Container maria3 sur IN3
'name' => 'rca_geo',
'username' => 'rca_geo_user',
'password' => 'UPf3C0cQ805LypyM71iW',
// Configuration actuelle - base locale dans rca-geo
'host' => 'localhost',
'name' => 'geo_app',
'username' => 'geo_app_user_rec',
'password' => 'UPf3C0cQ805LypyM71iW', // À ajuster si nécessaire
// Configuration AVANT migration (base locale dans rca-geo) - DÉSACTIVÉE
// 'host' => 'localhost',
// 'name' => 'geo_app',
// 'username' => 'geo_app_user_rec',
// 'password' => 'UPf3C0cQ805LypyM71iW',
],
'addresses_database' => [
'host' => '13.23.33.4', // Container maria3 sur IN3
@@ -132,15 +157,21 @@ class AppConfig {
'username' => 'adr_geo_user',
'password' => 'd66,AdrGeoRec.User',
],
'buildings_database' => [
'host' => '13.23.33.4', // Container maria3 sur IN3
'name' => 'batiments',
'username' => 'adr_geo_user',
'password' => 'd66,AdrGeoRec.User',
],
// Configuration Stripe RECETTE - Clés TEST du CLIENT
'stripe' => [
'public_key_test' => 'pk_test_51S5oMd1tQE0jBEomd1u28D1bUujOcl87ASuGf9xulcz4rY27QfHrLBtQj20MVlWta4AGXsX0YMfeOJFE66AlGlkz00vG30U8Rr',
'secret_key_test' => 'sk_test_51S5oMd1tQE0jBEomAhzPBvUcCf0HX9ydK0xq7DagKnidp3JsovbQoVaTj24TKSUPvujQA3PP7IpIS8iWzAd15Rte00TETmbimh',
'public_key_test' => 'pk_test_51S5oN00EZ9a0jvy2VSPjAYyCiJWci8lwfuakc0wpStt5YWq8RlQWyliICYIWHwTaejeW8uMSKA6KTfsfUAOvjRi500XPXWRFhJ',
'secret_key_test' => 'sk_test_51S5oN00EZ9a0jvy2paTcHY91Alh5QIMJLJZJGJ188jXqte5AkxwymbLoLDiLcCn0uQH41WC75UM03HPDDp04gl7h00wfno08gE',
'public_key_live' => 'pk_live_XXXXXX', // Non utilisé en REC
'secret_key_live' => 'sk_live_XXXXXX', // Non utilisé en REC
'webhook_secret_test' => 'whsec_test_XXXXXXXXXXXX',
'webhook_secret_test' => 'whsec_avExshr0MeWTI7wXP8478XVUkrbYG8hs',
'webhook_secret_live' => 'whsec_live_XXXXXXXXXXXX',
'api_version' => '2024-06-20',
'api_version' => '2025-08-27.basil',
'application_fee_percent' => 0,
'application_fee_minimum' => 0,
'mode' => 'test', // ← MODE TEST pour la recette
@@ -151,17 +182,17 @@ class AppConfig {
$this->config['dapp.geosector.fr'] = array_merge($baseConfig, [
'env' => 'development',
'database' => [
// Configuration future avec maria3 (à activer après migration)
// 'host' => '13.23.33.4', // Container maria3 sur IN3
// 'name' => 'dva_geo',
// 'username' => 'dva_geo_user',
// 'password' => 'CBq9tKHj6PGPZuTmAHV7',
// Configuration maria3 (migration effectuée le 07/10/2025)
'host' => '13.23.33.4', // Container maria3 sur IN3
'name' => 'dva_geo',
'username' => 'dva_geo_user',
'password' => 'CBq9tKHj6PGPZuTmAHV7',
// Configuration actuelle - base locale dans dva-geo
'host' => 'localhost',
'name' => 'geo_app',
'username' => 'geo_app_user_dev',
'password' => 'CBq9tKHj6PGPZuTmAHV7', // À ajuster si nécessaire
// Configuration locale AVANT migration (sauvegarde)
// 'host' => 'localhost',
// 'name' => 'geo_app',
// 'username' => 'geo_app_user_dev',
// 'password' => 'CBq9tKHj6PGPZuTmAHV7',
],
'addresses_database' => [
'host' => '13.23.33.4', // Container maria3 sur IN3
@@ -169,6 +200,12 @@ class AppConfig {
'username' => 'adr_geo_user',
'password' => 'd66,AdrGeoDev.User',
],
'buildings_database' => [
'host' => '13.23.33.4', // Container maria3 sur IN3
'name' => 'batiments',
'username' => 'adr_geo_user',
'password' => 'd66,AdrGeoDev.User',
],
// Configuration Stripe DÉVELOPPEMENT - Clés TEST de Pierre (plateforme de test existante)
'stripe' => [
'public_key_test' => 'pk_test_51QwoVN00pblGEgsXkf8qlXmLGEpxDQcG0KLRpjrGLjJHd7AVZ4Iwd6ChgdjO0w0n3vRqwNCEW8KnHUe5eh3uIlkV00k07kCBmd',
@@ -177,7 +214,7 @@ class AppConfig {
'secret_key_live' => 'sk_live_XXXXXX', // Non utilisé en DEV
'webhook_secret_test' => 'whsec_test_XXXXXXXXXXXX',
'webhook_secret_live' => 'whsec_live_XXXXXXXXXXXX',
'api_version' => '2024-06-20',
'api_version' => '2025-08-27.basil',
'application_fee_percent' => 0,
'application_fee_minimum' => 0,
'mode' => 'test', // ← MODE TEST pour le développement
@@ -197,13 +234,20 @@ class AppConfig {
// Si l'hôte n'existe pas dans la configuration, tenter une correction
if (!isset($this->config[$this->currentHost])) {
// Gestion des cas spéciaux (anciennes URLs)
if ($this->currentHost === 'app.geosector.fr') {
$this->currentHost = 'app3.geosector.fr';
}
// Essayer de faire correspondre avec l'un des hôtes connus
$knownHosts = array_keys($this->config);
foreach ($knownHosts as $host) {
if (strpos($this->currentHost, str_replace(['app.', 'rapp.', 'dapp.'], '', $host)) !== false) {
// Correspondance trouvée, utiliser cette configuration
$this->currentHost = $host;
break;
if (!isset($this->config[$this->currentHost])) {
$knownHosts = array_keys($this->config);
foreach ($knownHosts as $host) {
if (strpos($this->currentHost, str_replace(['app3.', 'rapp.', 'dapp.'], '', $host)) !== false) {
// Correspondance trouvée, utiliser cette configuration
$this->currentHost = $host;
break;
}
}
}
@@ -231,7 +275,7 @@ class AppConfig {
/**
* Retourne l'identifiant de l'application basé sur l'hôte
*
* @return string L'identifiant de l'application (app.geosector.fr, rapp.geosector.fr, dapp.geosector.fr)
* @return string L'identifiant de l'application (app3.geosector.fr, rapp.geosector.fr, dapp.geosector.fr)
*/
public function getAppIdentifier(): string {
return $this->currentHost;
@@ -293,7 +337,7 @@ class AppConfig {
/**
* Retourne la configuration de la base de données
*
*
* @return array Configuration de la base de données
*/
public function getDatabaseConfig(): array {
@@ -302,13 +346,22 @@ class AppConfig {
/**
* Retourne la configuration de la base de données des adresses
*
*
* @return array Configuration de la base de données des adresses
*/
public function getAddressesDatabaseConfig(): array {
return $this->getCurrentConfig()['addresses_database'];
}
/**
* Retourne la configuration de la base de données des bâtiments
*
* @return array Configuration de la base de données des bâtiments
*/
public function getBuildingsDatabaseConfig(): array {
return $this->getCurrentConfig()['buildings_database'];
}
/**
* Retourne la clé de chiffrement
*
@@ -410,13 +463,23 @@ class AppConfig {
/**
* Retourne l'adresse IP du client
*
*
* @return string L'adresse IP du client
*/
public function getClientIp(): string {
return $this->clientIp;
}
/**
* Vérifie si la redirection vers RECETTE est activée
*
* @return bool True si la redirection est activée
*/
public function shouldRedirectToRecette(): bool {
$value = getenv('REDIRECT_TO_REC');
return $value === 'true' || $value === '1';
}
/**
* Retourne la configuration des backups
*

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 {

View File

@@ -11,10 +11,11 @@ class Router {
'register',
'lostpassword',
'log',
'health', // Health check endpoint pour monitoring et déploiement
'villes', // Ajout de la route villes comme endpoint public pour l'autocomplétion du code postal
'password/check', // Vérification de la force des mots de passe (public pour l'inscription)
'password/compromised', // Vérification si un mot de passe est compromis
'stripe/webhook', // Webhook Stripe (doit être public pour recevoir les événements)
'stripe/webhooks', // Webhook Stripe (doit être public pour recevoir les événements)
];
public function __construct() {
@@ -34,6 +35,9 @@ class Router {
// Route pour les logs
$this->post('log', ['LogController', 'index']);
// Route health check (monitoring et déploiement)
$this->get('health', ['HealthController', 'check']);
// Routes privées utilisateurs
// IMPORTANT: Les routes spécifiques doivent être déclarées AVANT les routes avec paramètres
$this->post('users/check-username', ['UserController', 'checkUsername']); // Déplacé avant les routes avec :id
@@ -131,12 +135,14 @@ class Router {
$this->get('stripe/accounts/:entityId/status', ['StripeController', 'getAccountStatus']);
$this->post('stripe/accounts/:accountId/onboarding-link', ['StripeController', 'createOnboardingLink']);
// Tap to Pay - Vérification compatibilité
// Tap to Pay - Vérification compatibilité et configuration
$this->post('stripe/devices/check-tap-to-pay', ['StripeController', 'checkTapToPayCapability']);
$this->get('stripe/devices/certified-android', ['StripeController', 'getCertifiedAndroidDevices']);
$this->post('stripe/locations', ['StripeController', 'createLocation']);
// Paiements
$this->post('stripe/payments/create-intent', ['StripeController', 'createPaymentIntent']);
$this->post('stripe/payment-links', ['StripeController', 'createPaymentLink']);
$this->get('stripe/payments/:paymentIntentId', ['StripeController', 'getPaymentStatus']);
// Statistiques et configuration
@@ -144,7 +150,21 @@ class Router {
$this->get('stripe/config', ['StripeController', 'getPublicConfig']);
// Webhook (IMPORTANT: pas d'authentification requise pour les webhooks Stripe)
$this->post('stripe/webhook', ['StripeWebhookController', 'handleWebhook']);
$this->post('stripe/webhooks', ['StripeWebhookController', 'handleWebhook']);
// Routes Migration (Admin uniquement)
$this->get('migrations/test-connections', ['MigrationController', 'testConnections']);
$this->get('migrations/entities/available', ['MigrationController', 'getAvailableEntities']);
$this->get('migrations/entities/:id', ['MigrationController', 'getEntityDetails']);
$this->post('migrations/entity', ['MigrationController', 'migrateEntity']);
$this->post('migrations/entity/step', ['MigrationController', 'migrateEntityStep']);
$this->get('migrations/entity/:id/status', ['MigrationController', 'getMigrationStatus']);
$this->get('migrations/entity/:id/logs', ['MigrationController', 'getMigrationLogs']);
$this->get('migrations/entity/:id/report', ['MigrationController', 'getMigrationReport']);
$this->get('migrations/entity/:id/compare', ['MigrationController', 'compareEntityData']);
$this->get('migrations/entity/:id/verify', ['MigrationController', 'verifyMigration']);
$this->delete('migrations/entity/:id', ['MigrationController', 'rollbackEntity']);
$this->delete('migrations/entity/:id/step/:step', ['MigrationController', 'rollbackStep']);
}
public function handle(): void {
@@ -180,7 +200,6 @@ class Router {
// Check if endpoint is public
if ($this->isPublicEndpoint($endpoint)) {
error_log("Public endpoint found: $endpoint");
$route = $this->findRoute($method, $endpoint);
if ($route) {
$this->executeRoute($route);

View File

@@ -5,22 +5,55 @@ declare(strict_types=1);
class Session {
public static function start(): void {
if (session_status() === PHP_SESSION_NONE) {
// Configuration d'un répertoire de sessions dédié et persistant
$sessionPath = __DIR__ . '/../../sessions';
if (!is_dir($sessionPath)) {
mkdir($sessionPath, 0700, true);
}
ini_set('session.save_path', $sessionPath);
// Configuration des sessions adaptée pour les applications mobiles
ini_set('session.use_strict_mode', '1');
ini_set('session.cookie_httponly', '1');
// Permettre les connexions non-HTTPS en développement
$isProduction = (getenv('APP_ENV') === 'production');
ini_set('session.cookie_secure', $isProduction ? '1' : '0');
// SameSite None pour permettre les requêtes cross-origin (applications mobiles)
ini_set('session.cookie_samesite', 'None');
ini_set('session.gc_maxlifetime', '86400'); // 24 heures
// Configuration de la durée de vie des sessions : 24 heures
$sessionLifetime = 86400; // 24 heures
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
ini_set('session.cookie_lifetime', (string)$sessionLifetime);
// Configuration du garbage collector pour qu'il ne supprime pas trop tôt
// gc_probability / gc_divisor = probabilité d'exécution (1/100 = 1%)
ini_set('session.gc_probability', '1');
ini_set('session.gc_divisor', '100');
// Récupérer le session_id du Bearer token si présent
self::getSessionFromBearer();
session_start();
// Log détaillé après le démarrage de la session (DEBUG)
$logFile = __DIR__ . '/../../logs/session_' . date('Y-m-d') . '.log';
$sessionId = session_id();
$sessionExists = isset($_SESSION) && !empty($_SESSION);
$sessionData = $sessionExists ? json_encode($_SESSION) : 'empty';
$sessionFile = $sessionPath . '/sess_' . $sessionId;
$sessionFileExists = file_exists($sessionFile);
$logMessage = date('Y-m-d H:i:s') . " - Session started\n";
$logMessage .= " Session ID: $sessionId\n";
$logMessage .= " Session path: $sessionPath\n";
$logMessage .= " Session file exists: " . ($sessionFileExists ? 'YES' : 'NO') . "\n";
$logMessage .= " Session data exists: " . ($sessionExists ? 'YES' : 'NO') . "\n";
$logMessage .= " Session data: $sessionData\n";
file_put_contents($logFile, $logMessage, FILE_APPEND);
}
}

View File

@@ -1,17 +1,35 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/LogService.php';
namespace App\Services;
class AddressService {
use Database;
use PDO;
use PDOException;
use InvalidArgumentException;
use RuntimeException;
/**
* Service de gestion des adresses
*
* Ce service interroge la base de données externe 'adresses' pour récupérer
* les adresses géographiques dans des secteurs définis.
*/
class AddressService
{
private ?PDO $addressesDb = null;
private PDO $mainDb;
private LogService $logService;
public function __construct() {
private $logService;
private $buildingService;
public function __construct()
{
$this->logService = new LogService();
try {
$this->addressesDb = AddressesDatabase::getInstance();
$this->addressesDb = \AddressesDatabase::getInstance();
$this->logService->info('[AddressService] Connexion à la base d\'adresses réussie');
} catch (\Exception $e) {
// Si la connexion échoue, on continue sans la base d'adresses
@@ -21,53 +39,59 @@ class AddressService {
]);
$this->addressesDb = null;
}
$this->mainDb = Database::getInstance();
$this->mainDb = \Database::getInstance();
$this->buildingService = new BuildingService();
}
/**
* Vérifie si la connexion à la base d'adresses est active
*
* @return bool
*/
public function isConnected(): bool {
public function isConnected(): bool
{
return $this->addressesDb !== null;
}
/**
* Détermine le département de l'entité courante
*
*
* @param int|null $entityId ID de l'entité
* @return string|null Code département (ex: "22", "23")
*/
private function getDepartmentForEntity(?int $entityId = null): ?string {
private function getDepartmentForEntity(?int $entityId = null): ?string
{
if (!$entityId) {
$entityId = $_SESSION['entity_id'] ?? null;
}
if (!$entityId) {
return null;
}
try {
$query = "SELECT departement FROM entites WHERE id = :entity_id";
$stmt = $this->mainDb->prepare($query);
$stmt->execute(['entity_id' => $entityId]);
$result = $stmt->fetch();
return $result ? $result['departement'] : null;
} catch (\Exception $e) {
return null;
}
}
/**
* Récupère toutes les adresses contenues dans un polygone défini par des coordonnées
* Gère automatiquement les secteurs multi-départements
*
*
* @param array $coordinates Array de coordonnées [[lat, lng], [lat, lng], ...]
* @param int|null $entityId ID de l'entité (pour déterminer le département principal)
* @return array Array des adresses trouvées
*/
public function getAddressesInPolygon(array $coordinates, ?int $entityId = null): array {
public function getAddressesInPolygon(array $coordinates, ?int $entityId = null): array
{
// Si pas de connexion à la base d'adresses, retourner un tableau vide
if (!$this->addressesDb) {
$this->logService->error('[AddressService] Pas de connexion à la base d\'adresses externe', [
@@ -75,21 +99,20 @@ class AddressService {
]);
return [];
}
$this->logService->info('[AddressService] Début recherche adresses', [
'entity_id' => $entityId,
'nb_coordinates' => count($coordinates)
]);
if (count($coordinates) < 3) {
throw new InvalidArgumentException("Un polygone doit avoir au moins 3 points");
}
// D'abord, déterminer tous les départements touchés par ce secteur
require_once __DIR__ . '/DepartmentBoundaryService.php';
$boundaryService = new \DepartmentBoundaryService();
$boundaryService = new DepartmentBoundaryService();
$departmentsTouched = $boundaryService->getDepartmentsForSector($coordinates);
if (empty($departmentsTouched)) {
// Si aucun département n'est trouvé par analyse spatiale,
// chercher d'abord dans le département de l'entité et ses limitrophes
@@ -103,22 +126,22 @@ class AddressService {
]);
throw new RuntimeException("Impossible de déterminer le département");
}
// Obtenir les départements prioritaires (entité + limitrophes)
$priorityDepts = $boundaryService->getPriorityDepartments($entityDept);
// Log pour debug
$this->logService->warning('[AddressService] Aucun département trouvé par analyse spatiale', [
'departements_prioritaires' => implode(', ', $priorityDepts)
]);
// Utiliser les départements prioritaires pour la recherche
$departmentsTouched = [];
foreach ($priorityDepts as $deptCode) {
$departmentsTouched[] = ['code_dept' => $deptCode];
}
}
// Créer le polygone SQL à partir des coordonnées
$polygonPoints = [];
foreach ($coordinates as $coord) {
@@ -127,22 +150,22 @@ class AddressService {
}
$polygonPoints[] = $coord[1] . ' ' . $coord[0]; // MySQL attend longitude latitude
}
// Fermer le polygone
$polygonPoints[] = $polygonPoints[0];
$polygonString = 'POLYGON((' . implode(',', $polygonPoints) . '))';
// Collecter les adresses de tous les départements touchés
$allAddresses = [];
foreach ($departmentsTouched as $dept) {
$deptCode = $dept['code_dept'];
$tableName = "cp" . $deptCode;
try {
// Requête pour récupérer les adresses dans le polygone pour ce département
$sql = "SELECT
$sql = "SELECT
id,
numero,
rue as voie,
@@ -161,32 +184,32 @@ class AddressService {
:dept_code as departement
FROM `$tableName`
WHERE ST_Contains(
ST_GeomFromText(:polygon, 4326),
ST_GeomFromText(:polygon, 4326),
POINT(CAST(gps_lng AS DECIMAL(10,8)), CAST(gps_lat AS DECIMAL(10,8)))
)
AND gps_lat != ''
AND gps_lng != ''";
$stmt = $this->addressesDb->prepare($sql);
$stmt->execute([
'polygon' => $polygonString,
'dept_code' => $deptCode
]);
$addresses = $stmt->fetchAll();
// Ajouter les adresses à la collection globale
foreach ($addresses as $address) {
$allAddresses[] = $address;
}
// Log pour debug
$this->logService->info('[AddressService] Recherche dans table', [
'table' => $tableName,
'departement' => $deptCode,
'nb_adresses' => count($addresses)
]);
} catch (PDOException $e) {
// Log l'erreur mais continue avec les autres départements
$this->logService->error('[AddressService] Erreur SQL', [
@@ -197,35 +220,90 @@ class AddressService {
]);
}
}
$this->logService->info('[AddressService] Fin recherche adresses', [
'total_adresses' => count($allAddresses)
]);
return $allAddresses;
}
/**
* Enrichit les adresses avec les données bâtiments depuis la base 'batiments'
*
* Pour chaque adresse trouvée, cette méthode cherche si un bâtiment existe
* et ajoute les métadonnées (nb_log, residence, fk_habitat, etc.)
*
* @param array $addresses Liste d'adresses depuis getAddressesInPolygon()
* @param int|null $entityId ID de l'entité (pour logs)
* @return array Adresses enrichies avec données bâtiment
*/
public function enrichAddressesWithBuildings(array $addresses, ?int $entityId = null): array
{
if (empty($addresses)) {
return [];
}
$this->logService->info('[AddressService] Début enrichissement avec bâtiments', [
'entity_id' => $entityId,
'nb_addresses' => count($addresses)
]);
try {
$enrichedAddresses = $this->buildingService->enrichAddresses($addresses);
// Compter les immeubles vs maisons
$nbImmeubles = 0;
$nbMaisons = 0;
foreach ($enrichedAddresses as $addr) {
if (isset($addr['fk_habitat']) && $addr['fk_habitat'] == 2) {
$nbImmeubles++;
} else {
$nbMaisons++;
}
}
$this->logService->info('[AddressService] Fin enrichissement avec bâtiments', [
'total_adresses' => count($enrichedAddresses),
'nb_immeubles' => $nbImmeubles,
'nb_maisons' => $nbMaisons
]);
return $enrichedAddresses;
} catch (\Exception $e) {
$this->logService->error('[AddressService] Erreur lors de l\'enrichissement', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
// En cas d'erreur, retourner les adresses sans enrichissement
return $addresses;
}
}
/**
* Récupère les adresses dans un rayon autour d'un point
*
*
* @param float $latitude Latitude du centre
* @param float $longitude Longitude du centre
* @param float $radiusMeters Rayon en mètres
* @param int|null $entityId ID de l'entité (pour déterminer le département)
* @return array Array des adresses trouvées
*/
public function getAddressesInRadius(float $latitude, float $longitude, float $radiusMeters, ?int $entityId = null): array {
public function getAddressesInRadius(float $latitude, float $longitude, float $radiusMeters, ?int $entityId = null): array
{
// Déterminer le département
$dept = $this->getDepartmentForEntity($entityId);
if (!$dept) {
throw new RuntimeException("Impossible de déterminer le département de l'entité");
}
// Nom de la table selon le département
$tableName = "cp" . $dept;
try {
// Utiliser ST_Distance_Sphere pour calculer la distance en mètres
$sql = "SELECT
$sql = "SELECT
id,
numero,
rue as voie,
@@ -245,45 +323,44 @@ class AddressService {
AND gps_lat != ''
AND gps_lng != ''
ORDER BY distance";
$point = "POINT($longitude $latitude)";
$stmt = $this->addressesDb->prepare($sql);
$stmt->execute([
'point' => $point,
'radius' => $radiusMeters
]);
return $stmt->fetchAll();
} catch (PDOException $e) {
throw new RuntimeException("Erreur lors de la récupération des adresses dans la table $tableName : " . $e->getMessage());
}
}
/**
* Compte le nombre d'adresses dans un polygone
* Gère automatiquement les secteurs multi-départements
*
*
* @param array $coordinates Array de coordonnées [[lat, lng], [lat, lng], ...]
* @param int|null $entityId ID de l'entité (pour déterminer le département principal)
* @return int Nombre d'adresses
*/
public function countAddressesInPolygon(array $coordinates, ?int $entityId = null): int {
public function countAddressesInPolygon(array $coordinates, ?int $entityId = null): int
{
// Si pas de connexion à la base d'adresses, retourner 0
if (!$this->addressesDb) {
error_log("AddressService: Pas de connexion à la base d'adresses, retour de 0 adresses");
return 0;
}
if (count($coordinates) < 3) {
throw new InvalidArgumentException("Un polygone doit avoir au moins 3 points");
}
// D'abord, déterminer tous les départements touchés par ce secteur
require_once __DIR__ . '/DepartmentBoundaryService.php';
$boundaryService = new \DepartmentBoundaryService();
$boundaryService = new DepartmentBoundaryService();
$departmentsTouched = $boundaryService->getDepartmentsForSector($coordinates);
if (empty($departmentsTouched)) {
// Si aucun département n'est trouvé, utiliser le département de l'entité
$dept = $this->getDepartmentForEntity($entityId);
@@ -292,7 +369,7 @@ class AddressService {
}
$departmentsTouched = [['code_dept' => $dept]];
}
// Créer le polygone SQL à partir des coordonnées
$polygonPoints = [];
foreach ($coordinates as $coord) {
@@ -301,19 +378,19 @@ class AddressService {
}
$polygonPoints[] = $coord[1] . ' ' . $coord[0]; // MySQL attend longitude latitude
}
// Fermer le polygone
$polygonPoints[] = $polygonPoints[0];
$polygonString = 'POLYGON((' . implode(',', $polygonPoints) . '))';
// Compter les adresses dans tous les départements touchés
$totalCount = 0;
foreach ($departmentsTouched as $dept) {
$deptCode = $dept['code_dept'];
$tableName = "cp" . $deptCode;
try {
$sql = "SELECT COUNT(*) as count
FROM `$tableName`
@@ -323,23 +400,20 @@ class AddressService {
)
AND gps_lat != ''
AND gps_lng != ''";
$stmt = $this->addressesDb->prepare($sql);
$stmt->execute(['polygon' => $polygonString]);
$result = $stmt->fetch();
$deptCount = (int)$result['count'];
$totalCount += $deptCount;
// Log pour debug
error_log("Département $deptCode : $deptCount adresses comptées");
} catch (PDOException $e) {
// Log l'erreur mais continue avec les autres départements
error_log("Erreur de comptage pour le département $deptCode : " . $e->getMessage());
}
}
return $totalCount;
}
}
}

View File

@@ -2,10 +2,13 @@
declare(strict_types=1);
namespace App\Services;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
use App\Services\PasswordSecurityService;
use AppConfig;
use App\Services\LogService;
require_once __DIR__ . '/EmailTemplates.php';
require_once __DIR__ . '/PasswordSecurityService.php';
@@ -70,11 +73,21 @@ class ApiService {
$mail->Body = EmailTemplates::getLostPasswordTemplate($name, $data['username'] ?? '', $data['password']);
break;
case 'password_reset':
$mail->Subject = 'Réinitialisation de votre mot de passe GEOSECTOR';
$mail->Body = EmailTemplates::getLostPasswordTemplate($name, $data['username'] ?? '', $data['password'] ?? '');
break;
case 'alert':
$mail->Subject = $data['subject'] ?? 'Alerte GEOSECTOR';
$mail->Body = EmailTemplates::getAlertTemplate($data['subject'] ?? 'Alerte', $data['message'] ?? '');
break;
case 'security_alert':
$mail->Subject = $data['subject'] ?? 'Alerte de Sécurité GEOSECTOR';
$mail->Body = $data['body'] ?? EmailTemplates::getAlertTemplate($data['subject'] ?? 'Alerte de Sécurité', $data['message'] ?? '');
break;
case 'receipt':
$mail->Subject = 'Reçu de passage GEOSECTOR';
$mail->Body = EmailTemplates::getReceiptTemplate(

View File

@@ -2,14 +2,18 @@
declare(strict_types=1);
namespace App\Services;
require_once __DIR__ . '/../Config/AppConfig.php';
require_once __DIR__ . '/LogService.php';
use Exception;
use AppConfig;
use App\Services\LogService;
/**
* Service de chiffrement et compression des sauvegardes
*
*
* Ce service gère le processus complet de sécurisation des backups JSON :
* 1. Compression GZIP pour réduire la taille
* 2. Chiffrement AES-256-CBC pour la sécurité

View File

@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
namespace App\Services;
use PDO;
use PDOException;
use AppConfig;
require_once __DIR__ . '/../Config/AppConfig.php';
/**
* Service de gestion des bâtiments
*
* Ce service enrichit les adresses trouvées par AddressService avec les métadonnées
* des bâtiments depuis la base de données 'batiments'.
*
* Fonctionnalités :
* - Enrichissement des adresses avec données bâtiment (nb_log, residence, etc.)
* - Identification automatique des immeubles (fk_habitat=2)
* - Gestion des adresses sans bâtiment (maisons individuelles, fk_habitat=1)
*/
class BuildingService
{
private ?PDO $dbBuildings = null;
private bool $connected = false;
public function __construct()
{
$this->initConnection();
}
/**
* Initialise la connexion à la base de données batiments
*/
private function initConnection(): void
{
try {
$config = AppConfig::getInstance()->getBuildingsDatabaseConfig();
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=utf8mb4',
$config['host'],
$config['name']
);
$this->dbBuildings = new PDO(
$dsn,
$config['username'],
$config['password'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
$this->connected = true;
} catch (PDOException $e) {
error_log("Erreur de connexion à la base batiments : " . $e->getMessage());
$this->connected = false;
}
}
/**
* Vérifie si la connexion à la base batiments est active
*
* @return bool True si connecté
*/
public function isConnected(): bool
{
return $this->connected;
}
/**
* Enrichit une liste d'adresses avec les données bâtiment
*
* Pour chaque adresse trouvée par AddressService, cette méthode cherche
* si un bâtiment existe dans bat{dept} avec le lien cle_interop_adr.
*
* @param array $addresses Liste d'adresses depuis AddressService
* Format attendu : ['id' => 'cp22.12345', 'departement' => '22', ...]
* @return array Adresses enrichies avec :
* - fk_batiment : batiment_groupe_id (ou null)
* - fk_habitat : 1=individuel, 2=collectif
* - nb_niveau : Nombre d'étages (ou null)
* - nb_log : Nombre de logements (ou null)
* - residence : Nom de la copropriété (ou '')
* - alt_sol : Altitude sol (ou null)
*/
public function enrichAddresses(array $addresses): array
{
// Si pas de connexion, retourner les adresses sans enrichissement
if (!$this->isConnected() || empty($addresses)) {
// Ajouter fk_habitat=1 par défaut (maison individuelle)
foreach ($addresses as &$address) {
$address['fk_batiment'] = null;
$address['fk_habitat'] = 1;
$address['nb_niveau'] = null;
$address['nb_log'] = null;
$address['residence'] = '';
$address['alt_sol'] = null;
}
return $addresses;
}
try {
// Grouper les adresses par département
$addressesByDept = [];
foreach ($addresses as $address) {
$dept = $this->extractDepartmentFromAddress($address);
if ($dept) {
$addressesByDept[$dept][] = $address;
}
}
// Enrichir les adresses département par département
$enrichedAddresses = [];
foreach ($addressesByDept as $dept => $deptAddresses) {
$enrichedDept = $this->enrichAddressesByDepartment((string)$dept, $deptAddresses);
$enrichedAddresses = array_merge($enrichedAddresses, $enrichedDept);
}
return $enrichedAddresses;
} catch (PDOException $e) {
error_log("Erreur lors de l'enrichissement des adresses avec batiments : " . $e->getMessage());
// En cas d'erreur, retourner les adresses avec valeurs par défaut
foreach ($addresses as &$address) {
$address['fk_batiment'] = null;
$address['fk_habitat'] = 1;
$address['nb_niveau'] = null;
$address['nb_log'] = null;
$address['residence'] = '';
$address['alt_sol'] = null;
}
return $addresses;
}
}
/**
* Enrichit les adresses d'un département spécifique
*
* @param string $dept Code du département (ex: '22', '35')
* @param array $addresses Liste des adresses du département
* @return array Adresses enrichies
*/
private function enrichAddressesByDepartment(string $dept, array $addresses): array
{
// Vérifier que la table bat{dept} existe
if (!$this->tableExists("bat{$dept}")) {
// Table inexistante, retourner avec valeurs par défaut
foreach ($addresses as &$address) {
$address['fk_batiment'] = null;
$address['fk_habitat'] = 1;
$address['nb_niveau'] = null;
$address['nb_log'] = null;
$address['residence'] = '';
$address['alt_sol'] = null;
}
return $addresses;
}
// Créer un mapping address_id => address pour retrouver rapidement
$addressMap = [];
$addressIds = [];
foreach ($addresses as $address) {
// Extraire l'ID BAN (partie après le point)
$addressId = $this->extractAddressId($address['id']);
if ($addressId) {
$addressIds[] = $addressId;
$addressMap[$addressId] = $address;
}
}
if (empty($addressIds)) {
// Pas d'IDs valides, retourner avec valeurs par défaut
foreach ($addresses as &$address) {
$address['fk_batiment'] = null;
$address['fk_habitat'] = 1;
$address['nb_niveau'] = null;
$address['nb_log'] = null;
$address['residence'] = '';
$address['alt_sol'] = null;
}
return $addresses;
}
// Requête pour récupérer les bâtiments
$placeholders = str_repeat('?,', count($addressIds) - 1) . '?';
$query = "
SELECT
b.cle_interop_adr,
b.batiment_groupe_id,
b.nb_niveau,
b.nb_log,
b.residence,
b.altitude_sol_mean
FROM bat{$dept} b
WHERE b.cle_interop_adr IN ($placeholders)
";
$stmt = $this->dbBuildings->prepare($query);
$stmt->execute($addressIds);
$buildings = $stmt->fetchAll();
// Créer un mapping cle_interop_adr => building
$buildingMap = [];
foreach ($buildings as $building) {
$buildingMap[$building['cle_interop_adr']] = $building;
}
// Enrichir les adresses
$enrichedAddresses = [];
foreach ($addresses as $address) {
$addressId = $this->extractAddressId($address['id']);
if ($addressId && isset($buildingMap[$addressId])) {
// Bâtiment trouvé : enrichir avec ses données
$building = $buildingMap[$addressId];
$address['fk_batiment'] = $building['batiment_groupe_id'];
$address['fk_habitat'] = 2; // Collectif
$address['nb_niveau'] = $building['nb_niveau'];
$address['nb_log'] = $building['nb_log'];
$address['residence'] = $building['residence'] ?? '';
$address['alt_sol'] = $building['altitude_sol_mean'];
} else {
// Pas de bâtiment : maison individuelle
$address['fk_batiment'] = null;
$address['fk_habitat'] = 1; // Individuel
$address['nb_niveau'] = null;
$address['nb_log'] = null;
$address['residence'] = '';
$address['alt_sol'] = null;
}
$enrichedAddresses[] = $address;
}
return $enrichedAddresses;
}
/**
* Extrait le code département depuis une adresse
*
* @param array $address Adresse depuis AddressService
* @return string|null Code département (ex: '22', '35')
*/
private function extractDepartmentFromAddress(array $address): ?string
{
// Méthode 1 : Clé 'departement' directement
if (isset($address['departement'])) {
return $address['departement'];
}
// Méthode 2 : Depuis code_postal
if (isset($address['code_postal'])) {
$cp = $address['code_postal'];
if (strlen($cp) === 4) {
return '0' . substr($cp, 0, 1);
}
return substr($cp, 0, 2);
}
// Méthode 3 : Depuis l'ID (format cp22.12345)
if (isset($address['id']) && strpos($address['id'], 'cp') === 0) {
preg_match('/^cp(\d{2})\./', $address['id'], $matches);
if (isset($matches[1])) {
return $matches[1];
}
}
return null;
}
/**
* Extrait l'ID BAN depuis l'ID complet (ex: cp22.12345 → 12345)
*
* @param string $fullId ID complet de l'adresse
* @return string|null ID BAN
*/
private function extractAddressId(string $fullId): ?string
{
if (strpos($fullId, '.') !== false) {
$parts = explode('.', $fullId);
return $parts[1] ?? null;
}
return $fullId;
}
/**
* Vérifie si une table existe dans la base batiments
*
* @param string $tableName Nom de la table (ex: 'bat22')
* @return bool True si la table existe
*/
private function tableExists(string $tableName): bool
{
try {
// SHOW TABLES LIKE ne supporte pas les placeholders PDO
// On échappe manuellement le nom de table (alphanumérique uniquement)
if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName)) {
error_log("Nom de table invalide : {$tableName}");
return false;
}
$stmt = $this->dbBuildings->query("SHOW TABLES LIKE '{$tableName}'");
return $stmt && $stmt->rowCount() > 0;
} catch (PDOException $e) {
error_log("Erreur lors de la vérification de la table {$tableName} : " . $e->getMessage());
return false;
}
}
}

View File

@@ -1,11 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Database;
use PDO;
use PDOException;
use RuntimeException;
class DepartmentBoundaryService {
private PDO $db;
public function __construct() {
$this->db = Database::getInstance();
$this->db = \Database::getInstance();
}
/**

View File

@@ -2,6 +2,8 @@
declare(strict_types=1);
namespace App\Services;
class EmailTemplates {
/**
* Template d'email de bienvenue
@@ -12,7 +14,7 @@ class EmailTemplates {
Votre compte a été créé avec succès sur <b>GeoSector</b>.<br><br>
<b>Identifiant :</b> $username<br>
<b>Mot de passe :</b> $password<br><br>
Vous pouvez vous connecter dès maintenant sur <a href=\"https://app.geosector.fr\">app.geosector.fr</a><br><br>
Vous pouvez vous connecter dès maintenant sur <a href=\"https://app3.geosector.fr\">app3.geosector.fr</a><br><br>
À très bientôt,<br>
L'équipe GeoSector";
}
@@ -36,7 +38,7 @@ class EmailTemplates {
</p>
<p>
Une fois que vous aurez reçu votre mot de passe, vous pourrez vous connecter sur
<a href=\"https://app.geosector.fr\" style='color:#007bff;'>app.geosector.fr</a>
<a href=\"https://app3.geosector.fr\" style='color:#007bff;'>app3.geosector.fr</a>
</p>
<br>
À très bientôt,<br>
@@ -59,7 +61,7 @@ class EmailTemplates {
</p>
<p>
Vous pouvez maintenant vous connecter avec votre identifiant (reçu dans un email précédent)
et ce mot de passe sur <a href=\"https://app.geosector.fr\" style='color:#007bff;'>app.geosector.fr</a>
et ce mot de passe sur <a href=\"https://app3.geosector.fr\" style='color:#007bff;'>app3.geosector.fr</a>
</p>
<p style='background:#fff3cd; padding:10px; border-radius:5px; margin-top:20px;'>
<b>Rappel :</b> Ne communiquez jamais votre mot de passe à un tiers. L'équipe GeoSector
@@ -78,7 +80,7 @@ class EmailTemplates {
Bonjour $name,<br><br>
Vous avez demandé la réinitialisation de votre mot de passe sur <b>GeoSector</b>.<br><br>
<b>Nouveau mot de passe :</b> $password<br><br>
Vous pouvez vous connecter avec ce nouveau mot de passe sur <a href=\"https://app.geosector.fr\">app.geosector.fr</a><br><br>
Vous pouvez vous connecter avec ce nouveau mot de passe sur <a href=\"https://app3.geosector.fr\">app3.geosector.fr</a><br><br>
À très bientôt,<br>
L'équipe GeoSector";
}
@@ -95,7 +97,8 @@ class EmailTemplates {
}
/**
* Template de reçu de passage
* Template de reçu de passage (ancien format simple)
* @deprecated Utiliser getReceiptDonationTemplate() pour les reçus de don
*/
public static function getReceiptTemplate(string $name, string $date, string $address, string $amount, string $paymentMethod): string {
return "
@@ -126,4 +129,69 @@ class EmailTemplates {
<p>À bientôt,</p>
<p>L'équipe GeoSector</p>";
}
/**
* Template d'email pour reçu de don avec pièce jointe PDF
* Utilisé lors de l'envoi automatique des reçus pour les dons (fk_type=1)
*
* @param array $data Données du reçu (passage_id, entite_name, donor_name, amount, donation_date, payment_method, entite_address, entite_email)
* @return string HTML de l'email
*/
public static function getReceiptDonationTemplate(array $data): string {
// Extraction des données avec valeurs par défaut
$passageId = $data['passage_id'] ?? '';
$entiteName = htmlspecialchars($data['entite_name'] ?? 'Amicale des Sapeurs-Pompiers');
$donorName = htmlspecialchars($data['donor_name'] ?? '');
$amount = htmlspecialchars($data['amount'] ?? '0,00');
$donationDate = htmlspecialchars($data['donation_date'] ?? date('d/m/Y'));
$paymentMethod = htmlspecialchars($data['payment_method'] ?? 'Espèces');
$entiteAddress = htmlspecialchars($data['entite_address'] ?? '');
$entiteEmail = htmlspecialchars($data['entite_email'] ?? '');
return "
<!DOCTYPE html>
<html>
<head>
<meta charset='UTF-8'>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.passage-id { text-align: right; font-size: 10px; color: #999; margin-bottom: 20px; }
.header { background-color: #f4f4f4; padding: 20px; text-align: center; border-radius: 5px; }
.content { padding: 20px 0; }
.footer { background-color: #f4f4f4; padding: 15px; text-align: center; font-size: 12px; border-radius: 5px; margin-top: 20px; }
.amount { font-size: 24px; font-weight: bold; color: #2c5aa0; }
</style>
</head>
<body>
<div class='container'>
<div class='passage-id'>$passageId</div>
<div class='header'>
<h2>$entiteName</h2>
</div>
<div class='content'>
<p>Bonjour Mme/M. $donorName,</p>
<p>Nous vous remercions chaleureusement pour votre don de <span class='amount'>$amount €</span> effectué le $donationDate.</p>
<p><strong>Mode de paiement :</strong> $paymentMethod</p>
<p>Vous trouverez ci-joint votre reçu.</p>
<p>Votre soutien est précieux pour nous permettre de poursuivre nos actions.</p>
<p>Cordialement,<br>L'équipe de $entiteName</p>
</div>
<div class='footer'>
<p><strong>$entiteName</strong><br>
$entiteAddress<br>
$entiteEmail</p>
</div>
</div>
</body>
</html>";
}
}

View File

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

View File

@@ -2,17 +2,24 @@
declare(strict_types=1);
namespace App\Services;
require_once __DIR__ . '/../../vendor/autoload.php';
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xls;
use PhpOffice\PhpSpreadsheet\Writer\Csv;
use PDO;
use Database;
use Session;
use App\Services\LogService;
use App\Services\ApiService;
require_once __DIR__ . '/../Services/FileService.php';
class ExportService {
private \PDO $db;
private PDO $db;
private FileService $fileService;
public function __construct() {
@@ -249,10 +256,11 @@ class ExportService {
p.fk_habitat, p.appt, p.niveau, p.encrypted_name, p.encrypted_email,
p.encrypted_phone, p.montant, p.fk_type_reglement, p.remarque,
p.fk_user, p.fk_sector, p.fk_operation,
u.encrypted_name as user_name, u.first_name as user_first_name, u.sect_name,
u.encrypted_name as user_name, ou.first_name as user_first_name, u.sect_name,
xtr.libelle as reglement_libelle
FROM ope_pass p
LEFT JOIN users u ON u.id = p.fk_user
LEFT JOIN ope_users ou ON ou.id = p.fk_user
LEFT JOIN users u ON u.id = ou.fk_user
LEFT JOIN x_types_reglements xtr ON xtr.id = p.fk_type_reglement
WHERE p.fk_operation = ? AND p.chk_active = 1
';
@@ -457,10 +465,11 @@ class ExportService {
SELECT
ous.id, ous.fk_sector, ous.fk_user, ous.created_at, ous.fk_operation,
s.libelle as sector_name,
u.encrypted_name as user_name, u.first_name
u.encrypted_name as user_name, ou.first_name
FROM ope_users_sectors ous
INNER JOIN ope_sectors s ON s.id = ous.fk_sector
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
WHERE ous.fk_operation = ? AND ous.chk_active = 1
ORDER BY s.libelle, u.encrypted_name
';
@@ -619,10 +628,11 @@ class ExportService {
p.passed_at, p.fk_type, p.numero, p.rue_bis, p.rue, p.ville,
p.fk_habitat, p.appt, p.niveau, p.encrypted_name, p.encrypted_email,
p.encrypted_phone, p.montant, p.fk_type_reglement, p.remarque,
u.encrypted_name as user_name, u.first_name as user_first_name, u.sect_name,
u.encrypted_name as user_name, ou.first_name as user_first_name, u.sect_name,
xtr.libelle as reglement_libelle
FROM ope_pass p
LEFT JOIN users u ON u.id = p.fk_user
LEFT JOIN ope_users ou ON ou.id = p.fk_user
LEFT JOIN users u ON u.id = ou.fk_user
LEFT JOIN x_types_reglements xtr ON xtr.id = p.fk_type_reglement
WHERE p.fk_operation = ? AND p.chk_active = 1
';

View File

@@ -2,8 +2,15 @@
declare(strict_types=1);
namespace App\Services;
require_once __DIR__ . '/../../vendor/autoload.php';
use PDO;
use Database;
use Session;
use App\Services\LogService;
class FileService {
private const BASE_UPLOADS_DIR = '/var/www/geosector/api/uploads';

View File

@@ -2,6 +2,10 @@
declare(strict_types=1);
namespace App\Services;
use AppConfig;
use ClientDetector;
class LogService {
public static function log(string $message, array $metadata = []): void {

View File

@@ -0,0 +1,791 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Database;
require_once __DIR__ . '/LogService.php';
require_once __DIR__ . '/ApiService.php';
use PDO;
use PDOException;
use Exception;
use AppConfig;
use App\Services\LogService;
use App\Services\ApiService;
class MigrationService {
private ?PDO $sourceDb = null;
private PDO $targetDb;
private AppConfig $appConfig;
private bool $sshTunnelCreated = false;
// Configuration SSH et base source (TODO: à déplacer dans AppConfig)
private const SSH_HOST = '212.83.164.111';
private const SSH_PORT = 52266;
private const SSH_USER = 'root';
private const SSH_KEY_FILE = '/root/.ssh/id_rsa_db2';
private const REMOTE_DB_HOST = '127.0.0.1';
private const REMOTE_DB_PORT = 3306;
private const SOURCE_DB_HOST = '127.0.0.1';
private const SOURCE_DB_NAME = 'geosector';
private const SOURCE_DB_USER = 'geo_front_user';
private const SOURCE_DB_PASS = 'd66,GeoFront.User';
private const SOURCE_DB_PORT = 13306;
// Ordre des étapes de migration
private const MIGRATION_STEPS = [
'x_devises',
'x_entites_types',
'x_types_passages',
'x_types_reglements',
'x_users_roles',
'x_pays',
'x_regions',
'x_departements',
'x_villes',
'entites',
'users',
'operations',
'ope_sectors',
'sectors_adresses',
'ope_users',
'ope_users_sectors',
'ope_pass',
'ope_pass_histo',
'medias'
];
public function __construct() {
$this->targetDb = Database::getInstance();
$this->appConfig = AppConfig::getInstance();
}
public function __destruct() {
$this->closeSshTunnel();
}
/**
* Crée un tunnel SSH vers le serveur distant
*/
private function createSshTunnel(): bool {
if ($this->sshTunnelCreated) {
return true;
}
// Vérifier si un tunnel est déjà en cours d'exécution
$checkCommand = "ps aux | grep 'ssh -[vf]* -N -L " . self::SOURCE_DB_PORT . ":' | grep -v grep";
exec($checkCommand, $output, $return_var);
if (empty($output)) {
// Créer le tunnel SSH
$command = sprintf(
"ssh -f -N -o StrictHostKeyChecking=no -L %d:%s:%d -p %d %s@%s -i %s 2>&1",
self::SOURCE_DB_PORT,
self::REMOTE_DB_HOST,
self::REMOTE_DB_PORT,
self::SSH_PORT,
self::SSH_USER,
self::SSH_HOST,
self::SSH_KEY_FILE
);
exec($command, $output, $return_var);
if ($return_var !== 0) {
LogService::log('Erreur lors de la création du tunnel SSH', [
'level' => 'error',
'output' => implode("\n", $output)
]);
return false;
}
// Attendre que le tunnel soit établi
sleep(2);
// Vérification du tunnel
$checkTunnel = "netstat -an | grep " . self::SOURCE_DB_PORT . " | grep LISTEN";
exec($checkTunnel, $tunnelOutput);
if (empty($tunnelOutput)) {
LogService::log('Le tunnel SSH semble créé mais le port n\'est pas en écoute', [
'level' => 'warning',
'port' => self::SOURCE_DB_PORT
]);
}
LogService::log('Tunnel SSH établi', [
'level' => 'info',
'port' => self::SOURCE_DB_PORT
]);
} else {
LogService::log('Un tunnel SSH est déjà en cours d\'exécution', [
'level' => 'info'
]);
}
$this->sshTunnelCreated = true;
return true;
}
/**
* Ferme le tunnel SSH
*/
private function closeSshTunnel(): void {
if (!$this->sshTunnelCreated) {
return;
}
$command = "ps aux | grep 'ssh -[vf]* -N -L " . self::SOURCE_DB_PORT . ":' | grep -v grep | awk '{print $2}' | xargs -r kill 2>/dev/null";
exec($command);
$this->sshTunnelCreated = false;
$this->sourceDb = null;
LogService::log('Tunnel SSH fermé', ['level' => 'info']);
}
/**
* Récupère la connexion à la base source
*/
private function getSourceConnection(): PDO {
if ($this->sourceDb !== null) {
return $this->sourceDb;
}
// Établir le tunnel SSH
if (!$this->createSshTunnel()) {
throw new Exception("Impossible d'établir le tunnel SSH");
}
// Options de connexion PDO
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4',
PDO::ATTR_TIMEOUT => 600
];
if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')) {
$options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false;
}
try {
// Se connecter à la base spécifique
$dsn = sprintf(
'mysql:host=%s;dbname=%s;port=%d',
self::SOURCE_DB_HOST,
self::SOURCE_DB_NAME,
self::SOURCE_DB_PORT
);
$this->sourceDb = new PDO($dsn, self::SOURCE_DB_USER, self::SOURCE_DB_PASS, $options);
LogService::log('Connexion établie à la base source', [
'level' => 'info',
'database' => self::SOURCE_DB_NAME
]);
return $this->sourceDb;
} catch (PDOException $e) {
$this->closeSshTunnel();
throw new Exception("Erreur de connexion à la base source: " . $e->getMessage());
}
}
/**
* Teste les connexions aux deux bases de données
*/
public function testConnections(): array {
$result = [
'source' => ['status' => 'error', 'message' => ''],
'target' => ['status' => 'error', 'message' => '']
];
// Test connexion source
try {
$sourceDb = $this->getSourceConnection();
$stmt = $sourceDb->query('SELECT DATABASE() as db, VERSION() as version');
$info = $stmt->fetch();
$result['source'] = [
'status' => 'success',
'database' => $info['db'],
'version' => $info['version'],
'message' => 'Connexion réussie'
];
} catch (Exception $e) {
$result['source']['message'] = $e->getMessage();
}
// Test connexion cible
try {
$stmt = $this->targetDb->query('SELECT DATABASE() as db, VERSION() as version');
$info = $stmt->fetch();
$result['target'] = [
'status' => 'success',
'database' => $info['db'],
'version' => $info['version'],
'message' => 'Connexion réussie'
];
} catch (Exception $e) {
$result['target']['message'] = $e->getMessage();
}
return $result;
}
/**
* Liste les entités disponibles à migrer depuis la base source
*/
public function getAvailableEntities(): array {
$sourceDb = $this->getSourceConnection();
$stmt = $sourceDb->query("
SELECT
rowid as id,
libelle as name,
cp as postal_code,
ville as city,
active,
date_creat as created_at
FROM users_entites
WHERE active = 1
ORDER BY libelle ASC
");
return $stmt->fetchAll();
}
/**
* Récupère les détails d'une entité source
*/
public function getEntityDetails(int $id): ?array {
$sourceDb = $this->getSourceConnection();
$stmt = $sourceDb->prepare("
SELECT
e.rowid as id,
e.libelle as name,
e.cp as postal_code,
e.ville as city,
e.active,
e.date_creat as created_at,
(SELECT COUNT(*) FROM users WHERE fk_entite = e.rowid) as users_count,
(SELECT COUNT(*) FROM operations WHERE fk_entite = e.rowid) as operations_count,
(SELECT COUNT(*) FROM ope_pass p
JOIN operations o ON p.fk_operation = o.rowid
WHERE o.fk_entite = e.rowid) as passages_count
FROM users_entites e
WHERE e.rowid = ?
");
$stmt->execute([$id]);
$entity = $stmt->fetch();
return $entity ?: null;
}
/**
* Migre une entité complète
*/
public function migrateEntity(
int $entityId,
?array $steps = null,
bool $dryRun = false,
bool $truncate = false
): array {
$startTime = microtime(true);
$migrationId = 'mig_' . time() . '_' . $entityId;
// Si aucune étape spécifiée, migrer toutes les étapes
if ($steps === null) {
$steps = self::MIGRATION_STEPS;
}
// Récupérer le nom de l'entité
$entityDetails = $this->getEntityDetails($entityId);
if (!$entityDetails) {
throw new Exception("Entité $entityId non trouvée");
}
$stepsCompleted = [];
$totalRecords = 0;
$totalErrors = 0;
$totalWarnings = 0;
LogService::log('Début de migration entité', [
'level' => 'info',
'migration_id' => $migrationId,
'entity_id' => $entityId,
'entity_name' => $entityDetails['name'],
'steps' => $steps,
'dry_run' => $dryRun
]);
foreach ($steps as $step) {
try {
$stepResult = $this->migrateStep($entityId, $step, $dryRun, []);
$stepsCompleted[] = [
'step' => $step,
'status' => 'success',
'records_migrated' => $stepResult['records_migrated'],
'duration_ms' => $stepResult['duration_ms']
];
$totalRecords += $stepResult['records_migrated'];
$totalWarnings += count($stepResult['warnings'] ?? []);
} catch (Exception $e) {
$stepsCompleted[] = [
'step' => $step,
'status' => 'error',
'error' => $e->getMessage(),
'duration_ms' => 0
];
$totalErrors++;
LogService::log('Erreur lors de la migration d\'une étape', [
'level' => 'error',
'migration_id' => $migrationId,
'step' => $step,
'error' => $e->getMessage()
]);
// Arrêter la migration en cas d'erreur critique
break;
}
}
$totalDuration = (microtime(true) - $startTime) * 1000;
LogService::log('Fin de migration entité', [
'level' => 'info',
'migration_id' => $migrationId,
'entity_id' => $entityId,
'total_records' => $totalRecords,
'total_errors' => $totalErrors,
'duration_ms' => $totalDuration
]);
return [
'entity_name' => $entityDetails['name'],
'migration_id' => $migrationId,
'steps_completed' => $stepsCompleted,
'total_duration_ms' => round($totalDuration, 2),
'summary' => [
'total_records' => $totalRecords,
'total_errors' => $totalErrors,
'total_warnings' => $totalWarnings
]
];
}
/**
* Migre une étape spécifique pour une entité
*/
public function migrateStep(
int $entityId,
string $step,
bool $dryRun = false,
array $options = []
): array {
$startTime = microtime(true);
LogService::log("Début migration étape: $step", [
'level' => 'info',
'entity_id' => $entityId,
'step' => $step,
'dry_run' => $dryRun
]);
// Appeler la méthode spécifique pour chaque étape
$methodName = 'migrate' . $this->snakeToPascal($step);
if (!method_exists($this, $methodName)) {
throw new Exception("Méthode de migration non trouvée pour l'étape: $step");
}
$result = $this->$methodName($entityId, $dryRun, $options);
$duration = (microtime(true) - $startTime) * 1000;
LogService::log("Fin migration étape: $step", [
'level' => 'info',
'entity_id' => $entityId,
'records_migrated' => $result['records_migrated'],
'duration_ms' => $duration
]);
return [
'records_migrated' => $result['records_migrated'],
'duration_ms' => round($duration, 2),
'warnings' => $result['warnings'] ?? [],
'details' => $result['details'] ?? []
];
}
/**
* Convertit snake_case en PascalCase
*/
private function snakeToPascal(string $string): string {
return str_replace('_', '', ucwords($string, '_'));
}
/**
* Migration des devises (x_devises)
*/
private function migrateXDevises(int $entityId, bool $dryRun, array $options): array {
// Les tables x_* sont globales, pas liées à une entité
$sourceDb = $this->getSourceConnection();
$stmt = $sourceDb->query("SELECT * FROM x_devises WHERE active = 1");
$records = $stmt->fetchAll();
if ($dryRun) {
return ['records_migrated' => count($records), 'warnings' => []];
}
$inserted = 0;
foreach ($records as $record) {
$stmt = $this->targetDb->prepare("
INSERT INTO x_devises (id, code, libelle, symbole, chk_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
code = VALUES(code),
libelle = VALUES(libelle),
symbole = VALUES(symbole),
chk_active = VALUES(chk_active),
updated_at = NOW()
");
$stmt->execute([
$record['rowid'],
$record['code'],
$record['libelle'],
$record['symbole'],
$record['active']
]);
$inserted++;
}
return ['records_migrated' => $inserted, 'warnings' => []];
}
/**
* Migration des types d'entités (x_entites_types)
*/
private function migrateXEntitesTypes(int $entityId, bool $dryRun, array $options): array {
$sourceDb = $this->getSourceConnection();
$stmt = $sourceDb->query("SELECT * FROM x_entites_types WHERE active = 1");
$records = $stmt->fetchAll();
if ($dryRun) {
return ['records_migrated' => count($records), 'warnings' => []];
}
$inserted = 0;
foreach ($records as $record) {
$stmt = $this->targetDb->prepare("
INSERT INTO x_entites_types (id, libelle, chk_active, created_at, updated_at)
VALUES (?, ?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
libelle = VALUES(libelle),
chk_active = VALUES(chk_active),
updated_at = NOW()
");
$stmt->execute([
$record['rowid'],
$record['libelle'],
$record['active']
]);
$inserted++;
}
return ['records_migrated' => $inserted, 'warnings' => []];
}
/**
* Méthodes de migration pour les autres tables x_* (structure similaire)
* TODO: Implémenter les autres méthodes
*/
private function migrateXTypesPassages(int $entityId, bool $dryRun, array $options): array {
// TODO: À implémenter
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
private function migrateXTypesReglements(int $entityId, bool $dryRun, array $options): array {
// TODO: À implémenter
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
private function migrateXUsersRoles(int $entityId, bool $dryRun, array $options): array {
// TODO: À implémenter
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
private function migrateXPays(int $entityId, bool $dryRun, array $options): array {
// TODO: À implémenter
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
private function migrateXRegions(int $entityId, bool $dryRun, array $options): array {
// TODO: À implémenter
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
private function migrateXDepartements(int $entityId, bool $dryRun, array $options): array {
// TODO: À implémenter
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
private function migrateXVilles(int $entityId, bool $dryRun, array $options): array {
// TODO: À implémenter
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
/**
* Migration de l'entité (users_entites -> entites)
*/
private function migrateEntites(int $entityId, bool $dryRun, array $options): array {
$sourceDb = $this->getSourceConnection();
$stmt = $sourceDb->prepare("SELECT * FROM users_entites WHERE rowid = ?");
$stmt->execute([$entityId]);
$entity = $stmt->fetch();
if (!$entity) {
throw new Exception("Entité $entityId non trouvée dans la base source");
}
if ($dryRun) {
return ['records_migrated' => 1, 'warnings' => []];
}
// Chiffrement des données sensibles
$encryptedName = ApiService::encryptData($entity['libelle']);
$encryptedEmail = !empty($entity['email']) ? ApiService::encryptSearchableData($entity['email']) : null;
// Gestion des téléphones
$phone = $entity['tel1'] ?? '';
$mobile = $entity['tel2'] ?? '';
// Détection mobile (commence par 06 ou 07)
if (preg_match('/^0[67]/', $phone)) {
$mobile = $phone;
$phone = $entity['tel2'] ?? '';
}
$encryptedPhone = !empty($phone) ? ApiService::encryptData($phone) : null;
$encryptedMobile = !empty($mobile) ? ApiService::encryptData($mobile) : null;
$encryptedIban = !empty($entity['iban']) ? ApiService::encryptData($entity['iban']) : null;
$encryptedBic = !empty($entity['bic']) ? ApiService::encryptData($entity['bic']) : null;
$stmt = $this->targetDb->prepare("
INSERT INTO entites (
id, fk_type, encrypted_name, encrypted_email, encrypted_phone, encrypted_mobile,
code_postal, ville, encrypted_iban, encrypted_bic, chk_active, chk_demo,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NOW(), NOW())
ON DUPLICATE KEY UPDATE
fk_type = VALUES(fk_type),
encrypted_name = VALUES(encrypted_name),
encrypted_email = VALUES(encrypted_email),
encrypted_phone = VALUES(encrypted_phone),
encrypted_mobile = VALUES(encrypted_mobile),
code_postal = VALUES(code_postal),
ville = VALUES(ville),
encrypted_iban = VALUES(encrypted_iban),
encrypted_bic = VALUES(encrypted_bic),
chk_active = VALUES(chk_active),
updated_at = NOW()
");
$stmt->execute([
$entity['rowid'],
$entity['fk_type'] ?? 1,
$encryptedName,
$encryptedEmail,
$encryptedPhone,
$encryptedMobile,
$entity['cp'] ?? null,
$entity['ville'] ?? null,
$encryptedIban,
$encryptedBic,
$entity['active']
]);
return ['records_migrated' => 1, 'warnings' => []];
}
/**
* Migration des utilisateurs
*/
private function migrateUsers(int $entityId, bool $dryRun, array $options): array {
// TODO: À implémenter (voir migrate_users.php pour la logique)
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
/**
* Migration des opérations
*/
private function migrateOperations(int $entityId, bool $dryRun, array $options): array {
// TODO: À implémenter
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
/**
* Autres méthodes de migration
* TODO: Implémenter toutes les méthodes pour chaque étape
*/
private function migrateOpeSectors(int $entityId, bool $dryRun, array $options): array {
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
private function migrateSectorsAdresses(int $entityId, bool $dryRun, array $options): array {
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
private function migrateOpeUsers(int $entityId, bool $dryRun, array $options): array {
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
private function migrateOpeUsersSectors(int $entityId, bool $dryRun, array $options): array {
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
private function migrateOpePass(int $entityId, bool $dryRun, array $options): array {
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
private function migrateOpePassHisto(int $entityId, bool $dryRun, array $options): array {
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
private function migrateMedias(int $entityId, bool $dryRun, array $options): array {
return ['records_migrated' => 0, 'warnings' => ['Non implémenté']];
}
/**
* Récupère le statut de migration d'une entité
*/
public function getMigrationStatus(int $entityId): array {
// TODO: Implémenter la vérification du statut
return [
'entity_id' => $entityId,
'migrated' => false,
'steps_completed' => [],
'last_migration_date' => null
];
}
/**
* Récupère les logs de migration d'une entité
*/
public function getMigrationLogs(int $entityId): array {
// TODO: Lire les logs depuis LogService
return [];
}
/**
* Génère un rapport de migration
*/
public function generateMigrationReport(int $entityId): array {
// TODO: Implémenter la génération de rapport
return [
'entity_id' => $entityId,
'generated_at' => date('Y-m-d H:i:s'),
'summary' => []
];
}
/**
* Compare les données source vs cible
*/
public function compareEntityData(int $entityId): array {
$sourceDb = $this->getSourceConnection();
$comparison = [];
// Comparer les counts pour chaque table
$tables = [
'users' => 'fk_entite',
'operations' => 'fk_entite'
];
foreach ($tables as $table => $fkColumn) {
$sourceTable = $table;
$targetTable = $table;
// Cas spéciaux
if ($table === 'users_entites') {
$sourceTable = 'users_entites';
$targetTable = 'entites';
}
// Count source
$stmt = $sourceDb->prepare("SELECT COUNT(*) as count FROM $sourceTable WHERE $fkColumn = ?");
$stmt->execute([$entityId]);
$sourceCount = $stmt->fetch()['count'];
// Count cible
$stmt = $this->targetDb->prepare("SELECT COUNT(*) as count FROM $targetTable WHERE $fkColumn = ?");
$stmt->execute([$entityId]);
$targetCount = $stmt->fetch()['count'];
$comparison[$table] = [
'source_count' => $sourceCount,
'target_count' => $targetCount,
'difference' => $targetCount - $sourceCount,
'status' => $sourceCount === $targetCount ? 'ok' : 'warning'
];
}
return $comparison;
}
/**
* Vérifie l'intégrité des données migrées
*/
public function verifyMigration(int $entityId): array {
// TODO: Implémenter les vérifications d'intégrité
return [
'entity_id' => $entityId,
'verified_at' => date('Y-m-d H:i:s'),
'checks' => [],
'errors' => [],
'warnings' => []
];
}
/**
* Annule la migration d'une entité (rollback)
*/
public function rollbackEntity(int $entityId): array {
// TODO: Implémenter le rollback complet
$deletedRecords = [];
// Supprimer dans l'ordre inverse des dépendances
$tables = array_reverse(self::MIGRATION_STEPS);
foreach ($tables as $table) {
// Logique de suppression selon la table
}
return ['deleted_records' => $deletedRecords];
}
/**
* Annule une étape spécifique de migration
*/
public function rollbackStep(int $entityId, string $step): array {
// TODO: Implémenter le rollback d'une étape
return ['deleted_records' => []];
}
}

View File

@@ -2,12 +2,12 @@
declare(strict_types=1);
namespace App\Services;
require_once __DIR__ . '/ApiService.php';
require_once __DIR__ . '/LogService.php';
use PDO;
use ApiService;
use LogService;
class OperationDataService {
@@ -221,13 +221,13 @@ class OperationDataService {
if (!empty($sectorIdsString)) {
// Utiliser ope_users au lieu de users pour avoir les données historiques
$usersSectorsStmt = $db->prepare(
"SELECT DISTINCT ou.fk_user as id, ou.first_name, ou.encrypted_name, ou.sect_name, us.fk_sector
"SELECT DISTINCT ou.fk_user as user_id, ou.id as ope_user_id, ou.first_name, ou.encrypted_name, ou.sect_name, us.fk_sector
FROM ope_users ou
JOIN ope_users_sectors us ON ou.fk_user = 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 ou.chk_active = 1
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 ou.chk_active = 1
AND ou.fk_user != ?" // Exclure l'utilisateur connecté
);
$usersSectorsStmt->execute([$activeOperationId, $userId]);

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Services;
use LogService;
use App\Services\LogService;
require_once __DIR__ . '/LogService.php';

View File

@@ -6,147 +6,99 @@ namespace App\Services;
require_once __DIR__ . '/../../vendor/autoload.php';
use FPDF;
use setasign\Fpdi\Fpdi;
/**
* Générateur de reçus PDF avec FPDF
* Supporte les logos PNG/JPG
* Générateur de reçus PDF avec FPDI (utilise un template PDF)
* Génère des PDF légers et valides en format paysage (A4 Landscape)
*/
class ReceiptPDFGenerator extends FPDF {
class ReceiptPDFGenerator extends Fpdi {
private const TEMPLATE_PATH = __DIR__ . '/../../docs/_recu_template.pdf';
private const DEFAULT_LOGO_PATH = __DIR__ . '/../../docs/_logo_recu.png';
private const LOGO_WIDTH = 40; // Largeur du logo en mm
private const LOGO_HEIGHT = 40; // Hauteur du logo en mm
private const LOGO_WIDTH = 45; // Largeur du logo en mm
/**
* Génère un reçu fiscal PDF
* Génère un reçu fiscal PDF et l'enregistre directement dans un fichier
*
* @param array $data Données du reçu
* @param string $outputPath Chemin complet du fichier PDF à créer
* @param string|null $logoPath Chemin vers le logo (optionnel)
* @return bool True si la génération a réussi
*/
public function generateReceipt(array $data, ?string $logoPath = null): string {
$this->AddPage();
$this->SetFont('Arial', '', 12);
// Déterminer quel logo utiliser
$logoToUse = null;
if ($logoPath && file_exists($logoPath)) {
$logoToUse = $logoPath;
} elseif (file_exists(self::DEFAULT_LOGO_PATH)) {
$logoToUse = self::DEFAULT_LOGO_PATH;
}
// Ajouter le logo (PNG ou JPG)
if ($logoToUse) {
try {
// Déterminer le type d'image
$imageInfo = getimagesize($logoToUse);
if ($imageInfo !== false) {
$type = '';
switch ($imageInfo[2]) {
case IMAGETYPE_JPEG:
$type = 'JPG';
break;
case IMAGETYPE_PNG:
$type = 'PNG';
break;
}
if ($type) {
// Position du logo : x=10, y=10, largeur=40mm, hauteur=40mm
$this->Image($logoToUse, 10, 10, self::LOGO_WIDTH, self::LOGO_HEIGHT, $type);
public function generateReceipt(array $data, string $outputPath, ?string $logoPath = null): bool {
try {
// Créer la page en orientation paysage (Landscape)
$this->AddPage('L');
// Importer le template PDF
if (file_exists(self::TEMPLATE_PATH)) {
$this->setSourceFile(self::TEMPLATE_PATH);
$tplIdx = $this->importPage(1);
$this->useTemplate($tplIdx);
}
// Configuration de base
$this->SetFont('Arial');
$this->SetFontSize(16);
$this->SetTextColor(50, 50, 50);
// Nom de l'amicale (en haut à droite du template)
$this->SetXY(116, 26);
$this->Write(0, $this->cleanText($data['entite_city'] ?? ''));
// Nom du donateur
$this->SetXY(35, 41);
$this->Write(0, $this->cleanText($data['donor_name'] ?? ''));
// Adresse du donateur
$this->SetXY(35, 55);
$this->Write(0, $this->cleanText($data['donor_address'] ?? ''));
// Montant et mode de règlement
$this->SetXY(48, 68);
$amount = $data['amount'] ?? '0';
$paymentMethod = !empty($data['payment_method'])
? ' en ' . mb_strtolower($data['payment_method'], 'UTF-8')
: '';
$this->Write(0, $this->cleanText($amount . ' euros' . $paymentMethod));
// Date du don
$this->SetXY(20, 82);
$this->Write(0, $this->cleanText($data['donation_date'] ?? date('d/m/Y')));
// Logo de l'entité (en haut à droite)
$logoToUse = null;
if ($logoPath && file_exists($logoPath)) {
$logoToUse = $logoPath;
} elseif (file_exists(self::DEFAULT_LOGO_PATH)) {
$logoToUse = self::DEFAULT_LOGO_PATH;
}
if ($logoToUse) {
try {
$this->Image($logoToUse, 245, 8, self::LOGO_WIDTH);
} catch (\Exception $e) {
// Si erreur avec le logo custom, utiliser le logo par défaut
if ($logoToUse !== self::DEFAULT_LOGO_PATH && file_exists(self::DEFAULT_LOGO_PATH)) {
try {
$this->Image(self::DEFAULT_LOGO_PATH, 245, 8, self::LOGO_WIDTH);
} catch (\Exception $e2) {
// Continuer sans logo
}
}
}
} catch (\Exception $e) {
// Si erreur avec le logo, continuer sans
}
// Écrire directement dans le fichier (mode 'F')
$this->Output($outputPath, 'F');
return true;
} catch (\Exception $e) {
error_log('Erreur génération PDF: ' . $e->getMessage());
return false;
}
// En-tête à droite du logo
$this->SetXY(60, 20);
$this->SetFont('Arial', 'B', 14);
$this->Cell(130, 6, $this->cleanText(strtoupper($data['entite_name'] ?? '')), 0, 1, 'C');
if (!empty($data['entite_city'])) {
$this->SetX(60);
$this->SetFont('Arial', '', 11);
$this->Cell(130, 5, $this->cleanText(strtoupper($data['entite_city'])), 0, 1, 'C');
}
if (!empty($data['entite_address'])) {
$this->SetX(60);
$this->SetFont('Arial', '', 10);
$this->Cell(130, 5, $this->cleanText($data['entite_address']), 0, 1, 'C');
}
// Titre du reçu
$this->SetY(65);
$this->SetFont('Arial', 'B', 16);
$this->Cell(0, 10, $this->cleanText('REÇU DE DON'), 0, 1, 'C');
$this->SetFont('Arial', 'B', 14);
$this->Cell(0, 8, $this->cleanText('N° ' . ($data['receipt_number'] ?? '')), 0, 1, 'C');
// Ligne de séparation
$this->Ln(5);
$this->Line(20, $this->GetY(), 190, $this->GetY());
$this->Ln(8);
// Informations du donateur
$this->SetFont('Arial', 'B', 12);
$this->Cell(0, 6, $this->cleanText('DONATEUR :'), 0, 1, 'L');
$this->SetFont('Arial', '', 11);
$this->Cell(0, 6, $this->cleanText($data['donor_name'] ?? ''), 0, 1, 'L');
if (!empty($data['donor_address'])) {
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, $this->cleanText($data['donor_address']), 0, 1, 'L');
}
$this->Ln(8);
// Cadre pour le montant
$this->SetFillColor(240, 240, 240);
$this->Rect(20, $this->GetY(), 170, 25, 'F');
// Montant en gros et centré
$this->Ln(5);
$this->SetFont('Arial', 'B', 18);
$this->Cell(0, 8, $this->cleanText($data['amount'] ?? '0') . $this->cleanText(' euros'), 0, 1, 'C');
// Date centrée
$this->SetFont('Arial', '', 12);
$this->Cell(0, 6, $this->cleanText('Don du ' . ($data['donation_date'] ?? date('d/m/Y'))), 0, 1, 'C');
$this->Ln(10);
if (!empty($data['payment_method'])) {
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, $this->cleanText('Mode de règlement : ') . $this->cleanText($data['payment_method']), 0, 1, 'L');
}
if (!empty($data['operation_name'])) {
$this->SetFont('Arial', 'I', 10);
$this->Cell(0, 5, $this->cleanText('Campagne : ') . $this->cleanText($data['operation_name']), 0, 1, 'L');
}
// Mention de remerciement
$this->Ln(15);
$this->SetFont('Arial', '', 10);
$this->MultiCell(0, 5, $this->cleanText(
"Nous vous remercions pour votre généreux soutien à notre amicale.\n" .
"Votre don contribue au financement de nos activités et équipements."
), 0, 'C');
// Signature
$this->SetY(-60);
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, $this->cleanText('Fait à ' . ($data['entite_city'] ?? '') . ', le ' . ($data['signature_date'] ?? date('d/m/Y'))), 0, 1, 'R');
$this->Ln(5);
$this->Cell(0, 5, $this->cleanText('Le Président'), 0, 1, 'R');
$this->Ln(15);
$this->Cell(0, 5, $this->cleanText('(Signature et cachet)'), 0, 1, 'R');
// Retourner le PDF en string
return $this->Output('S');
}
/**

View File

@@ -8,12 +8,13 @@ require_once __DIR__ . '/LogService.php';
require_once __DIR__ . '/ApiService.php';
require_once __DIR__ . '/FileService.php';
require_once __DIR__ . '/ReceiptPDFGenerator.php';
require_once __DIR__ . '/EmailTemplates.php';
use PDO;
use Database;
use LogService;
use ApiService;
use FileService;
use App\Services\LogService;
use App\Services\FileService;
use App\Services\ApiService;
use Exception;
use DateTime;
@@ -88,26 +89,28 @@ class ReceiptService {
// Préparer les données pour la génération du PDF
$receiptData = $this->prepareReceiptData($passageData, $operationData, $entiteData, $email);
// Générer le PDF optimisé
$pdfContent = $this->generateOptimizedPDF($receiptData, $logoPath);
// Créer le répertoire de stockage
$uploadPath = "/{$operationData['fk_entite']}/recus/{$operationData['id']}";
$fullPath = $this->fileService->createDirectory($operationData['fk_entite'], $uploadPath);
// Nom du fichier
$fileName = 'recu_' . $passageId . '.pdf';
$filePath = $fullPath . '/' . $fileName;
// Sauvegarder le fichier
if (file_put_contents($filePath, $pdfContent) === false) {
throw new Exception('Impossible de sauvegarder le fichier PDF');
// Générer le PDF directement dans le fichier
$pdfGenerated = $this->generateOptimizedPDF($receiptData, $filePath, $logoPath);
if (!$pdfGenerated || !file_exists($filePath)) {
throw new Exception('Impossible de générer le fichier PDF');
}
// Appliquer les permissions
$this->fileService->setFilePermissions($filePath);
// Récupérer la taille du fichier généré
$fileSize = filesize($filePath);
// Enregistrer dans la table medias
$mediaId = $this->saveToMedias(
$operationData['fk_entite'],
@@ -115,21 +118,21 @@ class ReceiptService {
$passageId,
$fileName,
$filePath,
strlen($pdfContent)
$fileSize
);
// Mettre à jour le passage avec les infos du reçu
$this->updatePassageReceipt($passageId, $fileName);
// Ajouter à la queue d'email
$this->queueReceiptEmail($passageId, $email, $receiptData, $pdfContent);
// Ajouter à la queue d'email (le PDF sera lu depuis le fichier)
$this->queueReceiptEmail($passageId, $email, $receiptData, $filePath);
LogService::log('Reçu généré avec succès', [
'level' => 'info',
'passageId' => $passageId,
'mediaId' => $mediaId,
'fileName' => $fileName,
'fileSize' => strlen($pdfContent)
'fileSize' => $fileSize
]);
return true;
@@ -146,10 +149,15 @@ class ReceiptService {
/**
* Génère un PDF optimisé avec logo et mise en page épurée
*
* @param array $data Données du reçu
* @param string $outputPath Chemin du fichier PDF à créer
* @param string|null $logoPath Chemin du logo
* @return bool True si la génération a réussi
*/
private function generateOptimizedPDF(array $data, ?string $logoPath): string {
private function generateOptimizedPDF(array $data, string $outputPath, ?string $logoPath): bool {
$pdf = new ReceiptPDFGenerator();
return $pdf->generateReceipt($data, $logoPath);
return $pdf->generateReceipt($data, $outputPath, $logoPath);
}
@@ -158,12 +166,13 @@ class ReceiptService {
*/
private function getPassageData(int $passageId): ?array {
$stmt = $this->db->prepare('
SELECT p.*,
SELECT p.*,
u.encrypted_name as user_encrypted_name,
u.encrypted_email as user_encrypted_email,
u.encrypted_phone as user_encrypted_phone
FROM ope_pass p
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.id = ? AND p.chk_active = 1
');
$stmt->execute([$passageId]);
@@ -345,25 +354,52 @@ class ReceiptService {
/**
* Ajoute le reçu à la queue d'email
*
* @param int $passageId ID du passage
* @param string $email Email du destinataire
* @param array $receiptData Données du reçu
* @param string $pdfFilePath Chemin vers le fichier PDF
*/
private function queueReceiptEmail(int $passageId, string $email, array $receiptData, string $pdfContent): void {
private function queueReceiptEmail(int $passageId, string $email, array $receiptData, string $pdfFilePath): void {
// Lire le contenu du PDF depuis le fichier
if (!file_exists($pdfFilePath)) {
throw new \Exception('Fichier PDF introuvable pour la mise en queue: ' . $pdfFilePath);
}
$pdfContent = file_get_contents($pdfFilePath);
if ($pdfContent === false) {
throw new \Exception('Impossible de lire le fichier PDF: ' . $pdfFilePath);
}
// Préparer le sujet
$subject = "Votre reçu de don " . $receiptData['receipt_number'];
// Préparer le corps de l'email
$body = $this->generateEmailBody($receiptData);
$subject = "Votre reçu de don - " . $receiptData['entite_name'];
// Préparer les données pour le template
$templateData = [
'passage_id' => $passageId,
'entite_name' => $receiptData['entite_name'],
'donor_name' => $receiptData['donor_name'],
'amount' => $receiptData['amount'],
'donation_date' => $receiptData['donation_date'],
'payment_method' => $receiptData['payment_method'],
'entite_address' => $receiptData['entite_address'],
'entite_email' => $receiptData['entite_email']
];
// Générer le corps de l'email via le template centralisé
$body = EmailTemplates::getReceiptDonationTemplate($templateData);
// Préparer les headers avec pièce jointe
$boundary = md5((string)time());
$headers = "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: multipart/mixed; boundary=\"$boundary\"\r\n";
// Corps complet avec pièce jointe
$fullBody = "--$boundary\r\n";
$fullBody .= "Content-Type: text/html; charset=UTF-8\r\n";
$fullBody .= "Content-Transfer-Encoding: 7bit\r\n\r\n";
$fullBody .= $body . "\r\n\r\n";
// Pièce jointe PDF
$fullBody .= "--$boundary\r\n";
$fullBody .= "Content-Type: application/pdf; name=\"recu_" . $receiptData['receipt_number'] . ".pdf\"\r\n";
@@ -371,14 +407,14 @@ class ReceiptService {
$fullBody .= "Content-Disposition: attachment; filename=\"recu_" . $receiptData['receipt_number'] . ".pdf\"\r\n\r\n";
$fullBody .= chunk_split(base64_encode($pdfContent)) . "\r\n";
$fullBody .= "--$boundary--";
// Insérer dans la queue
$stmt = $this->db->prepare('
INSERT INTO email_queue (
fk_pass, to_email, subject, body, headers, created_at, status
) VALUES (?, ?, ?, ?, ?, NOW(), ?)
');
$stmt->execute([
$passageId,
$email,
@@ -388,62 +424,7 @@ class ReceiptService {
'pending'
]);
}
/**
* Génère le corps HTML de l'email
*/
private function generateEmailBody(array $data): string {
// Convertir toutes les valeurs en string pour htmlspecialchars
$safeData = array_map(function($value) {
return is_string($value) ? $value : (string)$value;
}, $data);
$html = '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #f4f4f4; padding: 20px; text-align: center; }
.content { padding: 20px; }
.footer { background-color: #f4f4f4; padding: 10px; text-align: center; font-size: 12px; }
.amount { font-size: 24px; font-weight: bold; color: #2c5aa0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>' . htmlspecialchars($safeData['entite_name']) . '</h2>
</div>
<div class="content">
<p>Bonjour ' . htmlspecialchars($safeData['donor_name']) . ',</p>
<p>Nous vous remercions chaleureusement pour votre don de <span class="amount">' .
htmlspecialchars($safeData['amount']) . ' €</span> effectué le ' .
htmlspecialchars($safeData['donation_date']) . '.</p>
<p>Vous trouverez ci-joint votre reçu fiscal N°' . htmlspecialchars($safeData['receipt_number']) .
' qui vous permettra de bénéficier d\'une réduction d\'impôt égale à 66% du montant de votre don.</p>
<p>Votre soutien est précieux pour nous permettre de poursuivre nos actions.</p>
<p>Cordialement,<br>
L\'équipe de ' . htmlspecialchars($safeData['entite_name']) . '</p>
</div>
<div class="footer">
<p>Conservez ce reçu pour votre déclaration fiscale</p>
<p>' . htmlspecialchars($safeData['entite_name']) . '<br>
' . htmlspecialchars($safeData['entite_address']) . '<br>
' . htmlspecialchars($safeData['entite_email']) . '</p>
</div>
</div>
</body>
</html>';
return $html;
}
/**
* Met à jour la date d'envoi du reçu
*/

View File

@@ -9,8 +9,9 @@ require_once __DIR__ . '/EmailThrottler.php';
use PDO;
use Database;
use ApiService;
use App\Services\ApiService;
use AppConfig;
use App\Services\LogService;
/**
* Service central de gestion des alertes de sécurité et monitoring
@@ -94,7 +95,7 @@ class AlertService {
$context['request'] = [
'uri' => $_SERVER['REQUEST_URI'],
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'ip' => \AppConfig::getInstance()->getClientIp(),
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
];
}

View File

@@ -111,7 +111,7 @@ class PerformanceMonitor {
$memoryUsed = $memoryPeak - $memoryStart;
// Enrichir avec les infos de requête
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
$ip = \AppConfig::getInstance()->getClientIp();
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
$requestSize = strlen(file_get_contents('php://input'));

View File

@@ -74,13 +74,10 @@ class SecurityMonitor {
// Critères de détection
$isBruteForce = false;
$reason = '';
if ($attempts >= 5) {
if ($attempts >= 8) {
$isBruteForce = true;
$reason = "Plus de 5 tentatives en 5 minutes";
} elseif ($uniqueUsers >= 3) {
$isBruteForce = true;
$reason = "Tentatives sur 3 usernames différents";
$reason = "Plus de 8 tentatives en 5 minutes";
}
if ($isBruteForce) {
@@ -114,15 +111,19 @@ class SecurityMonitor {
try {
// Chercher si le username existe (pour stocker la version chiffrée)
require_once __DIR__ . '/../ApiService.php';
$encryptedUsername = null;
if ($username) {
// Chiffrer le username pour la recherche
$searchUsername = \ApiService::encryptSearchableData($username);
$userStmt = $db->prepare('
SELECT encrypted_user_name
FROM users
WHERE username = :username
SELECT encrypted_user_name
FROM users
WHERE encrypted_user_name = :encrypted_username
LIMIT 1
');
$userStmt->execute(['username' => $username]);
$userStmt->execute(['encrypted_username' => $searchUsername]);
$user = $userStmt->fetch(PDO::FETCH_ASSOC);
if ($user) {
$encryptedUsername = $user['encrypted_user_name'];
@@ -178,7 +179,7 @@ class SecurityMonitor {
if (isset($_SERVER['REQUEST_URI'])) {
$context['endpoint'] = $_SERVER['REQUEST_URI'];
$context['method'] = $_SERVER['REQUEST_METHOD'] ?? 'unknown';
$context['ip'] = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$context['ip'] = \AppConfig::getInstance()->getClientIp();
}
AlertService::trigger('SQL_INJECTION', $context, 'SECURITY');

View File

@@ -12,6 +12,8 @@ use AppConfig;
use Database;
use PDO;
use Exception;
use App\Services\LogService;
use App\Services\ApiService;
/**
* Service principal pour gérer l'intégration Stripe
@@ -85,10 +87,67 @@ class StripeService {
// Si le compte existe, vérifier s'il est complet
try {
$stripeAccount = $this->stripe->accounts->retrieve($existingAccount['stripe_account_id']);
// Si pas de location_id, créer la Location maintenant
$locationId = $existingAccount['stripe_location_id'];
if (empty($locationId)) {
try {
// Déchiffrer le nom pour la Location
$nom = !empty($entite['encrypted_name']) ? ApiService::decryptData($entite['encrypted_name']) : 'Amicale';
// Construire l'adresse
$adresse1 = !empty($entite['adresse1']) ? $entite['adresse1'] : 'Adresse non renseignée';
$adresse2 = !empty($entite['adresse2']) ? $entite['adresse2'] : '';
$ville = !empty($entite['ville']) ? $entite['ville'] : 'Ville';
$codePostal = !empty($entite['code_postal']) ? $entite['code_postal'] : '00000';
$location = $this->stripe->terminal->locations->create([
'display_name' => $nom,
'address' => [
'line1' => $adresse1,
'line2' => $adresse2,
'city' => $ville,
'postal_code' => $codePostal,
'country' => 'FR',
],
'metadata' => [
'entite_id' => $entiteId,
'type' => 'tap_to_pay'
]
], [
'stripe_account' => $existingAccount['stripe_account_id']
]);
$locationId = $location->id;
// Mettre à jour en base
$stmt = $this->db->prepare(
"UPDATE stripe_accounts
SET stripe_location_id = :location_id, updated_at = NOW()
WHERE fk_entite = :fk_entite"
);
$stmt->execute([
'location_id' => $locationId,
'fk_entite' => $entiteId
]);
LogService::log('Location créée pour compte existant', [
'entite_id' => $entiteId,
'location_id' => $locationId
]);
} catch (Exception $e) {
LogService::log('Erreur création Location pour compte existant', [
'level' => 'warning',
'entite_id' => $entiteId,
'error' => $e->getMessage()
]);
}
}
return [
'success' => true,
'account_id' => $existingAccount['stripe_account_id'],
'location_id' => $locationId,
'message' => 'Compte Stripe existant',
'existing' => true,
'charges_enabled' => $stripeAccount->charges_enabled,
@@ -106,8 +165,8 @@ class StripeService {
}
// Déchiffrer les données
$nom = !empty($entite['encrypted_name']) ? \ApiService::decryptData($entite['encrypted_name']) : '';
$email = !empty($entite['encrypted_email']) ? \ApiService::decryptSearchableData($entite['encrypted_email']) : null;
$nom = !empty($entite['encrypted_name']) ? ApiService::decryptData($entite['encrypted_name']) : '';
$email = !empty($entite['encrypted_email']) ? ApiService::decryptSearchableData($entite['encrypted_email']) : null;
// Créer le compte Stripe Connect Express
$accountData = [
@@ -147,21 +206,64 @@ class StripeService {
}
$account = $this->stripe->accounts->create($accountData);
// Sauvegarder en base de données
// Créer automatiquement la Location Terminal pour Tap to Pay
$location = null;
$locationId = null;
try {
// Construire l'adresse complète
$adresse1 = !empty($entite['adresse1']) ? $entite['adresse1'] : 'Adresse non renseignée';
$adresse2 = !empty($entite['adresse2']) ? $entite['adresse2'] : '';
$ville = !empty($entite['ville']) ? $entite['ville'] : 'Ville';
$codePostal = !empty($entite['code_postal']) ? $entite['code_postal'] : '00000';
$location = $this->stripe->terminal->locations->create([
'display_name' => $nom,
'address' => [
'line1' => $adresse1,
'line2' => $adresse2,
'city' => $ville,
'postal_code' => $codePostal,
'country' => 'FR',
],
'metadata' => [
'entite_id' => $entiteId,
'type' => 'tap_to_pay'
]
], [
'stripe_account' => $account->id
]);
$locationId = $location->id;
LogService::log('Location Stripe créée automatiquement', [
'entite_id' => $entiteId,
'location_id' => $locationId
]);
} catch (Exception $e) {
// Si la création de la Location échoue, logger mais continuer
LogService::log('Erreur création Location', [
'level' => 'warning',
'entite_id' => $entiteId,
'error' => $e->getMessage()
]);
}
// Sauvegarder en base de données avec le location_id
$stmt = $this->db->prepare(
"INSERT INTO stripe_accounts (fk_entite, stripe_account_id, created_at)
VALUES (:fk_entite, :stripe_account_id, NOW())"
"INSERT INTO stripe_accounts (fk_entite, stripe_account_id, stripe_location_id, created_at)
VALUES (:fk_entite, :stripe_account_id, :stripe_location_id, NOW())"
);
$stmt->execute([
'fk_entite' => $entiteId,
'stripe_account_id' => $account->id
'stripe_account_id' => $account->id,
'stripe_location_id' => $locationId
]);
return [
'success' => true,
'account_id' => $account->id,
'message' => 'Compte Stripe créé avec succès'
'location_id' => $locationId,
'message' => 'Compte Stripe créé avec succès' . ($locationId ? ' (Location Terminal créée)' : '')
];
} catch (ApiErrorException $e) {
@@ -197,41 +299,41 @@ class StripeService {
*/
public function createOnboardingLink(string $accountId, string $returnUrl, string $refreshUrl): array {
try {
\LogService::log('StripeService::createOnboardingLink début', [
LogService::log('StripeService::createOnboardingLink début', [
'account_id' => $accountId,
'return_url' => $returnUrl,
'refresh_url' => $refreshUrl
]);
$accountLink = $this->stripe->accountLinks->create([
'account' => $accountId,
'refresh_url' => $refreshUrl,
'return_url' => $returnUrl,
'type' => 'account_onboarding',
]);
\LogService::log('StripeService::createOnboardingLink succès', [
LogService::log('StripeService::createOnboardingLink succès', [
'url' => $accountLink->url
]);
return [
'success' => true,
'url' => $accountLink->url
];
} catch (ApiErrorException $e) {
\LogService::log('StripeService::createOnboardingLink erreur Stripe', [
LogService::log('StripeService::createOnboardingLink erreur Stripe', [
'level' => 'error',
'error' => $e->getMessage(),
'code' => $e->getCode()
]);
return [
'success' => false,
'message' => 'Erreur Stripe: ' . $e->getMessage()
];
} catch (\Exception $e) {
\LogService::log('StripeService::createOnboardingLink erreur générale', [
LogService::log('StripeService::createOnboardingLink erreur générale', [
'level' => 'error',
'error' => $e->getMessage()
]);
@@ -263,7 +365,7 @@ class StripeService {
}
// Déchiffrer les données de l'entité
$nom = !empty($data['encrypted_name']) ? \ApiService::decryptData($data['encrypted_name']) : 'Amicale';
$nom = !empty($data['encrypted_name']) ? ApiService::decryptData($data['encrypted_name']) : 'Amicale';
// Construire l'adresse complète
$adresse1 = !empty($data['adresse1']) ? $data['adresse1'] : '';
@@ -500,20 +602,165 @@ class StripeService {
}
}
/**
* Créer un Payment Link Stripe pour paiement par QR Code
*
* @param array $params [
* 'amount' => int (en centimes),
* 'currency' => string (défaut: 'eur'),
* 'description' => string,
* 'passage_id' => int,
* 'metadata' => array
* ]
* @return array ['success' => bool, 'payment_link_id' => string, 'url' => string, 'amount' => int]
*/
public function createPaymentLink(array $params): array {
try {
$amount = $params['amount'] ?? 0;
$passageId = $params['passage_id'] ?? 0;
if ($amount <= 0) {
throw new Exception("Le montant doit être supérieur à 0");
}
// Récupérer les infos du passage avec opération et entité
$stmt = $this->db->prepare("
SELECT p.*, o.fk_entite, o.id as operation_id, sa.stripe_account_id
FROM ope_pass p
JOIN operations o ON p.fk_operation = o.id
JOIN stripe_accounts sa ON o.fk_entite = sa.fk_entite
WHERE p.id = :passage_id
");
$stmt->execute(['passage_id' => $passageId]);
$passage = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$passage) {
throw new Exception("Passage non trouvé");
}
if (!$passage['stripe_account_id']) {
throw new Exception("Stripe non activé pour cette amicale");
}
// Préparer les metadata
$metadata = array_merge($params['metadata'] ?? [], [
'passage_id' => (string)$passageId,
'operation_id' => (string)$passage['operation_id'],
'amicale_id' => (string)$passage['fk_entite'],
'fk_user' => (string)$passage['fk_user'], // ID du membre (ope_users.id)
'created_at' => (string)time(), // Timestamp Unix de création du Payment Link
'type' => 'qr_code_payment'
]);
// Créer le Payment Link sur le compte Connect
$paymentLink = $this->stripe->paymentLinks->create([
'line_items' => [
[
'price_data' => [
'currency' => $params['currency'] ?? 'eur',
'product_data' => [
'name' => $params['description'] ?? 'Calendrier pompiers',
],
'unit_amount' => $amount,
],
'quantity' => 1,
],
],
'metadata' => $metadata,
'after_completion' => [
'type' => 'hosted_confirmation',
'hosted_confirmation' => [
'custom_message' => 'Merci pour votre paiement ! Votre reçu vous sera envoyé par email.',
],
],
'payment_method_types' => ['card'],
'billing_address_collection' => 'auto',
], [
'stripe_account' => $passage['stripe_account_id']
]);
// Logger la création
LogService::log('Payment Link créé', [
'payment_link_id' => $paymentLink->id,
'passage_id' => $passageId,
'amount' => $amount,
'amicale_id' => $passage['fk_entite']
]);
// Mettre à jour le passage avec le payment_link_id
$stmt = $this->db->prepare("
UPDATE ope_pass
SET stripe_payment_link_id = :link_id, updated_at = NOW()
WHERE id = :passage_id
");
$stmt->execute([
'link_id' => $paymentLink->id,
'passage_id' => $passageId
]);
return [
'success' => true,
'payment_link_id' => $paymentLink->id,
'url' => $paymentLink->url,
'amount' => $amount
];
} catch (ApiErrorException $e) {
LogService::log('Erreur Stripe Payment Link', [
'level' => 'error',
'error' => $e->getMessage(),
'passage_id' => $passageId ?? null
]);
return [
'success' => false,
'message' => 'Erreur Stripe: ' . $e->getMessage()
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Récupérer le statut d'un PaymentIntent depuis Stripe
*/
public function getPaymentIntentStatus(string $paymentIntentId): array {
try {
$paymentIntent = $this->stripe->paymentIntents->retrieve($paymentIntentId);
return [
'success' => true,
'status' => $paymentIntent->status,
'amount' => $paymentIntent->amount,
'currency' => $paymentIntent->currency,
'payment_method' => $paymentIntent->payment_method,
'created' => $paymentIntent->created
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Obtenir le mode actuel (test ou live)
*/
public function isTestMode(): bool {
return $this->testMode;
}
/**
* Obtenir la clé publique pour le frontend
*/
public function getPublicKey(): string {
$stripeConfig = $this->config->getStripeConfig();
return $this->testMode
? $stripeConfig['public_key_test']
return $this->testMode
? $stripeConfig['public_key_test']
: $stripeConfig['public_key_live'];
}
}