feat: Release version 3.1.4 - Mode terrain et génération PDF
✨ Nouvelles fonctionnalités: - Ajout du mode terrain pour utilisation mobile hors connexion - Génération automatique de reçus PDF avec template personnalisé - Révision complète du système de cartes avec amélioration des performances 🔧 Améliorations techniques: - Refactoring du module chat avec architecture simplifiée - Optimisation du système de sécurité NIST SP 800-63B - Amélioration de la gestion des secteurs géographiques - Support UTF-8 étendu pour les noms d'utilisateurs 📱 Application mobile: - Nouveau mode terrain dans user_field_mode_page - Interface utilisateur adaptative pour conditions difficiles - Synchronisation offline améliorée 🗺️ Cartographie: - Optimisation des performances MapBox - Meilleure gestion des tuiles hors ligne - Amélioration de l'affichage des secteurs 📄 Documentation: - Ajout guide Android (ANDROID-GUIDE.md) - Documentation sécurité API (API-SECURITY.md) - Guide module chat (CHAT_MODULE.md) 🐛 Corrections: - Résolution des erreurs 400 lors de la création d'utilisateurs - Correction de la validation des noms d'utilisateurs - Fix des problèmes de synchronisation chat 🤖 Generated with Claude Code (https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
875
api/src/Controllers/ChatController.php
Normal file
875
api/src/Controllers/ChatController.php
Normal file
@@ -0,0 +1,875 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
require_once __DIR__ . '/../Services/LogService.php';
|
||||
require_once __DIR__ . '/../Services/ApiService.php';
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use Database;
|
||||
use Request;
|
||||
use Response;
|
||||
use Session;
|
||||
use LogService;
|
||||
use ApiService;
|
||||
|
||||
class ChatController {
|
||||
private PDO $db;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/chat/rooms
|
||||
* Liste des conversations filtrées par rôle et entité
|
||||
*/
|
||||
public function getRooms(): void {
|
||||
Session::requireAuth();
|
||||
|
||||
try {
|
||||
$userId = Session::getUserId();
|
||||
$entityId = Session::getEntityId();
|
||||
|
||||
// Récupérer le rôle de l'utilisateur
|
||||
$userRole = $this->getUserRole($userId);
|
||||
|
||||
// Construction de la requête selon le rôle
|
||||
$sql = '
|
||||
SELECT DISTINCT
|
||||
r.id,
|
||||
r.title,
|
||||
r.type,
|
||||
r.created_at,
|
||||
r.created_by,
|
||||
r.updated_at,
|
||||
-- Dernier message
|
||||
(SELECT m.content
|
||||
FROM chat_messages m
|
||||
WHERE m.room_id = r.id
|
||||
AND m.is_deleted = 0
|
||||
ORDER BY m.sent_at DESC
|
||||
LIMIT 1) as last_message,
|
||||
(SELECT m.sent_at
|
||||
FROM chat_messages m
|
||||
WHERE m.room_id = r.id
|
||||
AND m.is_deleted = 0
|
||||
ORDER BY m.sent_at DESC
|
||||
LIMIT 1) as last_message_at,
|
||||
-- Nombre de messages non lus
|
||||
(SELECT COUNT(*)
|
||||
FROM chat_messages m
|
||||
WHERE m.room_id = r.id
|
||||
AND m.sent_at > COALESCE(p.last_read_at, p.joined_at)
|
||||
AND m.sender_id != :user_id_count) as unread_count,
|
||||
-- Participants
|
||||
(SELECT COUNT(*)
|
||||
FROM chat_participants cp
|
||||
WHERE cp.room_id = r.id
|
||||
AND cp.left_at IS NULL) as participant_count
|
||||
FROM chat_rooms r
|
||||
INNER JOIN chat_participants p ON r.id = p.room_id
|
||||
WHERE r.is_active = 1
|
||||
AND p.user_id = :user_id
|
||||
AND p.left_at IS NULL
|
||||
';
|
||||
|
||||
// Filtrage supplémentaire selon le rôle
|
||||
if ($userRole == 1) {
|
||||
// Utilisateur simple : seulement ses conversations privées et de groupe
|
||||
$sql .= ' AND r.type IN ("private", "group")';
|
||||
} elseif ($userRole == 2) {
|
||||
// Admin d'entité : toutes les conversations de son entité
|
||||
$sql .= ' AND (p.entite_id = :entity_id OR r.type = "broadcast")';
|
||||
}
|
||||
// Rôle > 2 : accès à toutes les conversations
|
||||
|
||||
$sql .= ' ORDER BY COALESCE(last_message_at, r.created_at) DESC';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$params = [
|
||||
'user_id' => $userId,
|
||||
'user_id_count' => $userId
|
||||
];
|
||||
|
||||
if ($userRole == 2) {
|
||||
$params['entity_id'] = $entityId;
|
||||
}
|
||||
|
||||
$stmt->execute($params);
|
||||
$rooms = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Pour chaque room, récupérer les participants
|
||||
foreach ($rooms as &$room) {
|
||||
$room['participants'] = $this->getRoomParticipants($room['id']);
|
||||
}
|
||||
|
||||
LogService::log('Récupération des conversations', [
|
||||
'level' => 'debug',
|
||||
'user_id' => $userId,
|
||||
'room_count' => count($rooms)
|
||||
]);
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'rooms' => $rooms
|
||||
]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
LogService::log('Erreur lors de la récupération des conversations', [
|
||||
'level' => 'error',
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur serveur'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat/rooms
|
||||
* Créer une nouvelle conversation
|
||||
*/
|
||||
public function createRoom(): void {
|
||||
Session::requireAuth();
|
||||
|
||||
try {
|
||||
$data = Request::getJson();
|
||||
$userId = Session::getUserId();
|
||||
$entityId = Session::getEntityId();
|
||||
$userRole = $this->getUserRole($userId);
|
||||
|
||||
// Validation des données
|
||||
if (!isset($data['type']) || !in_array($data['type'], ['private', 'group', 'broadcast'])) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Type de conversation invalide'
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérification des permissions pour broadcast
|
||||
if ($data['type'] === 'broadcast' && $userRole < 2) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Permissions insuffisantes pour créer une diffusion'
|
||||
], 403);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation des participants
|
||||
if (!isset($data['participants']) || !is_array($data['participants']) || empty($data['participants'])) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Au moins un participant requis'
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pour une conversation privée, limiter à 2 participants (incluant le créateur)
|
||||
if ($data['type'] === 'private' && count($data['participants']) > 1) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Une conversation privée ne peut avoir que 2 participants'
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier que tous les participants existent et sont accessibles
|
||||
$participantIds = array_map('intval', $data['participants']);
|
||||
if (!in_array($userId, $participantIds)) {
|
||||
$participantIds[] = $userId; // Ajouter le créateur
|
||||
}
|
||||
|
||||
// Vérifier l'existence d'une conversation privée existante
|
||||
if ($data['type'] === 'private' && count($participantIds) === 2) {
|
||||
$existingRoom = $this->findExistingPrivateRoom($participantIds[0], $participantIds[1]);
|
||||
if ($existingRoom) {
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'room' => $existingRoom,
|
||||
'existing' => true
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Générer un UUID pour la room
|
||||
$roomId = $this->generateUUID();
|
||||
|
||||
// Titre de la conversation
|
||||
$title = $data['title'] ?? null;
|
||||
if (!$title && $data['type'] === 'private') {
|
||||
// Pour une conversation privée, pas de titre par défaut
|
||||
$title = null;
|
||||
}
|
||||
|
||||
$this->db->beginTransaction();
|
||||
|
||||
try {
|
||||
// Créer la room
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO chat_rooms (id, title, type, created_by, created_at)
|
||||
VALUES (:id, :title, :type, :created_by, NOW())
|
||||
');
|
||||
$stmt->execute([
|
||||
'id' => $roomId,
|
||||
'title' => $title,
|
||||
'type' => $data['type'],
|
||||
'created_by' => $userId
|
||||
]);
|
||||
|
||||
// Ajouter les participants
|
||||
$participantStmt = $this->db->prepare('
|
||||
INSERT INTO chat_participants (room_id, user_id, role, entite_id, is_admin)
|
||||
VALUES (:room_id, :user_id, :role, :entite_id, :is_admin)
|
||||
');
|
||||
|
||||
foreach ($participantIds as $participantId) {
|
||||
$participantData = $this->getUserData($participantId);
|
||||
if (!$participantData) {
|
||||
throw new \Exception("Participant invalide: $participantId");
|
||||
}
|
||||
|
||||
$participantStmt->execute([
|
||||
'room_id' => $roomId,
|
||||
'user_id' => $participantId,
|
||||
'role' => $participantData['fk_role'],
|
||||
'entite_id' => $participantData['fk_entite'],
|
||||
'is_admin' => ($participantId === $userId) ? 1 : 0
|
||||
]);
|
||||
}
|
||||
|
||||
// Si un message initial est fourni, l'envoyer
|
||||
if (isset($data['initial_message']) && !empty($data['initial_message'])) {
|
||||
$messageId = $this->generateUUID();
|
||||
$msgStmt = $this->db->prepare('
|
||||
INSERT INTO chat_messages (id, room_id, content, sender_id, sent_at)
|
||||
VALUES (:id, :room_id, :content, :sender_id, NOW())
|
||||
');
|
||||
$msgStmt->execute([
|
||||
'id' => $messageId,
|
||||
'room_id' => $roomId,
|
||||
'content' => $data['initial_message'],
|
||||
'sender_id' => $userId
|
||||
]);
|
||||
}
|
||||
|
||||
$this->db->commit();
|
||||
|
||||
LogService::log('Conversation créée', [
|
||||
'level' => 'info',
|
||||
'room_id' => $roomId,
|
||||
'type' => $data['type'],
|
||||
'created_by' => $userId,
|
||||
'participant_count' => count($participantIds)
|
||||
]);
|
||||
|
||||
// Récupérer la room créée avec ses détails
|
||||
$room = $this->getRoomDetails($roomId);
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'room' => $room
|
||||
], 201);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
LogService::log('Erreur lors de la création de la conversation', [
|
||||
'level' => 'error',
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur serveur'
|
||||
], 500);
|
||||
} catch (\Exception $e) {
|
||||
LogService::log('Erreur lors de la création de la conversation', [
|
||||
'level' => 'error',
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage()
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/chat/rooms/{id}/messages
|
||||
* Récupérer les messages d'une conversation
|
||||
*/
|
||||
public function getRoomMessages(string $roomId): void {
|
||||
Session::requireAuth();
|
||||
|
||||
try {
|
||||
$userId = Session::getUserId();
|
||||
|
||||
// Vérifier que l'utilisateur est participant
|
||||
if (!$this->isUserInRoom($userId, $roomId)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Accès non autorisé à cette conversation'
|
||||
], 403);
|
||||
return;
|
||||
}
|
||||
|
||||
// Paramètres de pagination
|
||||
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 50;
|
||||
$before = $_GET['before'] ?? null; // Message ID pour pagination
|
||||
|
||||
$sql = '
|
||||
SELECT
|
||||
m.id,
|
||||
m.content,
|
||||
m.sender_id,
|
||||
m.sent_at,
|
||||
m.edited_at,
|
||||
m.is_deleted,
|
||||
u.encrypted_name as sender_name,
|
||||
u.first_name as sender_first_name,
|
||||
-- Statut de lecture
|
||||
(SELECT COUNT(*)
|
||||
FROM chat_read_receipts r
|
||||
WHERE r.message_id = m.id) as read_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM chat_read_receipts r
|
||||
WHERE r.message_id = m.id
|
||||
AND r.user_id = :user_id) as is_read
|
||||
FROM chat_messages m
|
||||
INNER JOIN users u ON m.sender_id = u.id
|
||||
WHERE m.room_id = :room_id
|
||||
';
|
||||
|
||||
$params = [
|
||||
'room_id' => $roomId,
|
||||
'user_id' => $userId
|
||||
];
|
||||
|
||||
if ($before) {
|
||||
$sql .= ' AND m.sent_at < (SELECT sent_at FROM chat_messages WHERE id = :before)';
|
||||
$params['before'] = $before;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY m.sent_at DESC LIMIT :limit';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
foreach ($params as $key => $value) {
|
||||
if ($key === 'limit') {
|
||||
$stmt->bindValue($key, $value, PDO::PARAM_INT);
|
||||
} else {
|
||||
$stmt->bindValue($key, $value);
|
||||
}
|
||||
}
|
||||
$stmt->bindValue('limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
$messages = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Déchiffrer les noms
|
||||
foreach ($messages as &$message) {
|
||||
$message['sender_name'] = ApiService::decryptData($message['sender_name']);
|
||||
$message['is_read'] = (bool)$message['is_read'];
|
||||
$message['is_mine'] = ($message['sender_id'] == $userId);
|
||||
}
|
||||
|
||||
// Inverser pour avoir l'ordre chronologique
|
||||
$messages = array_reverse($messages);
|
||||
|
||||
// Mettre à jour last_read_at pour ce participant
|
||||
$this->updateLastRead($roomId, $userId);
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'messages' => $messages,
|
||||
'has_more' => count($messages) === $limit
|
||||
]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
LogService::log('Erreur lors de la récupération des messages', [
|
||||
'level' => 'error',
|
||||
'room_id' => $roomId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur serveur'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat/rooms/{id}/messages
|
||||
* Envoyer un message dans une conversation
|
||||
*/
|
||||
public function sendMessage(string $roomId): void {
|
||||
Session::requireAuth();
|
||||
|
||||
try {
|
||||
$data = Request::getJson();
|
||||
$userId = Session::getUserId();
|
||||
|
||||
// Vérifier que l'utilisateur est participant
|
||||
if (!$this->isUserInRoom($userId, $roomId)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Accès non autorisé à cette conversation'
|
||||
], 403);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation du contenu
|
||||
if (!isset($data['content']) || empty(trim($data['content']))) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Le message ne peut pas être vide'
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$content = trim($data['content']);
|
||||
|
||||
// Limiter la longueur du message
|
||||
if (mb_strlen($content, 'UTF-8') > 5000) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Message trop long (max 5000 caractères)'
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$messageId = $this->generateUUID();
|
||||
|
||||
// Insérer le message
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO chat_messages (id, room_id, content, sender_id, sent_at)
|
||||
VALUES (:id, :room_id, :content, :sender_id, NOW())
|
||||
');
|
||||
$stmt->execute([
|
||||
'id' => $messageId,
|
||||
'room_id' => $roomId,
|
||||
'content' => $content,
|
||||
'sender_id' => $userId
|
||||
]);
|
||||
|
||||
// Mettre à jour la date de dernière modification de la room
|
||||
$updateStmt = $this->db->prepare('
|
||||
UPDATE chat_rooms
|
||||
SET updated_at = NOW()
|
||||
WHERE id = :room_id
|
||||
');
|
||||
$updateStmt->execute(['room_id' => $roomId]);
|
||||
|
||||
// Récupérer le message créé avec les infos du sender
|
||||
$msgStmt = $this->db->prepare('
|
||||
SELECT
|
||||
m.id,
|
||||
m.content,
|
||||
m.sender_id,
|
||||
m.sent_at,
|
||||
u.encrypted_name as sender_name,
|
||||
u.first_name as sender_first_name
|
||||
FROM chat_messages m
|
||||
INNER JOIN users u ON m.sender_id = u.id
|
||||
WHERE m.id = :id
|
||||
');
|
||||
$msgStmt->execute(['id' => $messageId]);
|
||||
$message = $msgStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$message['sender_name'] = ApiService::decryptData($message['sender_name']);
|
||||
$message['is_mine'] = true;
|
||||
$message['is_read'] = false;
|
||||
$message['read_count'] = 0;
|
||||
|
||||
LogService::log('Message envoyé', [
|
||||
'level' => 'debug',
|
||||
'room_id' => $roomId,
|
||||
'message_id' => $messageId,
|
||||
'sender_id' => $userId
|
||||
]);
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'message' => $message
|
||||
], 201);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
LogService::log('Erreur lors de l\'envoi du message', [
|
||||
'level' => 'error',
|
||||
'room_id' => $roomId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur serveur'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat/rooms/{id}/read
|
||||
* Marquer les messages comme lus
|
||||
*/
|
||||
public function markAsRead(string $roomId): void {
|
||||
Session::requireAuth();
|
||||
|
||||
try {
|
||||
$data = Request::getJson();
|
||||
$userId = Session::getUserId();
|
||||
|
||||
// Vérifier que l'utilisateur est participant
|
||||
if (!$this->isUserInRoom($userId, $roomId)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Accès non autorisé à cette conversation'
|
||||
], 403);
|
||||
return;
|
||||
}
|
||||
|
||||
// Si des message_ids spécifiques sont fournis
|
||||
if (isset($data['message_ids']) && is_array($data['message_ids'])) {
|
||||
$messageIds = $data['message_ids'];
|
||||
|
||||
// Marquer ces messages spécifiques comme lus
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT IGNORE INTO chat_read_receipts (message_id, user_id, read_at)
|
||||
VALUES (:message_id, :user_id, NOW())
|
||||
');
|
||||
|
||||
foreach ($messageIds as $messageId) {
|
||||
$stmt->execute([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $userId
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Marquer tous les messages non lus de la room comme lus
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT IGNORE INTO chat_read_receipts (message_id, user_id, read_at)
|
||||
SELECT m.id, :user_id, NOW()
|
||||
FROM chat_messages m
|
||||
WHERE m.room_id = :room_id
|
||||
AND m.id NOT IN (
|
||||
SELECT message_id
|
||||
FROM chat_read_receipts
|
||||
WHERE user_id = :user_id_check
|
||||
)
|
||||
');
|
||||
$stmt->execute([
|
||||
'user_id' => $userId,
|
||||
'user_id_check' => $userId,
|
||||
'room_id' => $roomId
|
||||
]);
|
||||
}
|
||||
|
||||
// Mettre à jour last_read_at
|
||||
$this->updateLastRead($roomId, $userId);
|
||||
|
||||
// Compter les messages non lus restants
|
||||
$countStmt = $this->db->prepare('
|
||||
SELECT COUNT(*) as unread_count
|
||||
FROM chat_messages m
|
||||
WHERE m.room_id = :room_id
|
||||
AND m.sender_id != :user_id
|
||||
AND m.id NOT IN (
|
||||
SELECT message_id
|
||||
FROM chat_read_receipts
|
||||
WHERE user_id = :user_id_check
|
||||
)
|
||||
');
|
||||
$countStmt->execute([
|
||||
'room_id' => $roomId,
|
||||
'user_id' => $userId,
|
||||
'user_id_check' => $userId
|
||||
]);
|
||||
$result = $countStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'unread_count' => (int)$result['unread_count']
|
||||
]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
LogService::log('Erreur lors du marquage comme lu', [
|
||||
'level' => 'error',
|
||||
'room_id' => $roomId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur serveur'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/chat/recipients
|
||||
* Liste des destinataires possibles selon le rôle
|
||||
*/
|
||||
public function getRecipients(): void {
|
||||
Session::requireAuth();
|
||||
|
||||
try {
|
||||
$userId = Session::getUserId();
|
||||
$entityId = Session::getEntityId();
|
||||
$userRole = $this->getUserRole($userId);
|
||||
|
||||
$sql = '
|
||||
SELECT
|
||||
u.id,
|
||||
u.encrypted_name as name,
|
||||
u.first_name,
|
||||
u.sect_name,
|
||||
u.fk_role as role,
|
||||
u.fk_entite as entite_id,
|
||||
e.encrypted_name as entite_name
|
||||
FROM users u
|
||||
LEFT JOIN entites e ON u.fk_entite = e.id
|
||||
WHERE u.chk_active = 1
|
||||
AND u.id != :user_id
|
||||
';
|
||||
|
||||
$params = ['user_id' => $userId];
|
||||
|
||||
// Filtrage selon le rôle
|
||||
if ($userRole == 1) {
|
||||
// Utilisateur simple : seulement les utilisateurs de son entité
|
||||
$sql .= ' AND u.fk_entite = :entity_id';
|
||||
$params['entity_id'] = $entityId;
|
||||
} elseif ($userRole == 2) {
|
||||
// Admin d'entité :
|
||||
// - Tous les membres actifs de son amicale (même entité)
|
||||
// - Les super-admins (fk_role=9) de l'entité 1
|
||||
$sql .= ' AND (
|
||||
u.fk_entite = :entity_id
|
||||
OR (u.fk_role = 9 AND u.fk_entite = 1)
|
||||
)';
|
||||
$params['entity_id'] = $entityId;
|
||||
} elseif ($userRole == 9) {
|
||||
// Super-administrateur :
|
||||
// - Seulement les administrateurs actifs des amicales (fk_role=2)
|
||||
// - Et les autres super-admins (fk_role=9)
|
||||
$sql .= ' AND (u.fk_role = 2 OR u.fk_role = 9)';
|
||||
}
|
||||
// Autres rôles (3-8) : pas de filtrage spécifique pour le moment
|
||||
|
||||
$sql .= ' ORDER BY u.fk_entite, u.encrypted_name';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$recipients = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Déchiffrer les données et organiser par entité
|
||||
$recipientsByEntity = [];
|
||||
$recipientsDecrypted = [];
|
||||
|
||||
foreach ($recipients as &$recipient) {
|
||||
// Déchiffrer le nom
|
||||
$recipient['name'] = ApiService::decryptData($recipient['name']);
|
||||
|
||||
// Déchiffrer le nom de l'entité
|
||||
$entiteName = $recipient['entite_name'] ?
|
||||
ApiService::decryptData($recipient['entite_name']) :
|
||||
'Sans entité';
|
||||
|
||||
// Créer une copie pour recipients_by_entity
|
||||
$recipientCopy = $recipient;
|
||||
unset($recipientCopy['entite_name']);
|
||||
|
||||
// Organiser par entité
|
||||
if (!isset($recipientsByEntity[$entiteName])) {
|
||||
$recipientsByEntity[$entiteName] = [];
|
||||
}
|
||||
$recipientsByEntity[$entiteName][] = $recipientCopy;
|
||||
|
||||
// Remplacer entite_name chiffré par la version déchiffrée
|
||||
$recipient['entite_name'] = $entiteName;
|
||||
|
||||
// Ajouter à la liste déchiffrée
|
||||
$recipientsDecrypted[] = $recipient;
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'recipients' => $recipientsDecrypted,
|
||||
'recipients_by_entity' => $recipientsByEntity
|
||||
]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
LogService::log('Erreur lors de la récupération des destinataires', [
|
||||
'level' => 'error',
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur serveur'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Méthodes utilitaires privées =====
|
||||
|
||||
/**
|
||||
* Récupérer le rôle d'un utilisateur
|
||||
*/
|
||||
private function getUserRole(int $userId): int {
|
||||
$stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $result ? (int)$result['fk_role'] : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les données d'un utilisateur
|
||||
*/
|
||||
private function getUserData(int $userId): ?array {
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT id, fk_role, fk_entite, encrypted_name, first_name
|
||||
FROM users
|
||||
WHERE id = ? AND chk_active = 1
|
||||
');
|
||||
$stmt->execute([$userId]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si un utilisateur est dans une room
|
||||
*/
|
||||
private function isUserInRoom(int $userId, string $roomId): bool {
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT COUNT(*) as count
|
||||
FROM chat_participants
|
||||
WHERE room_id = ?
|
||||
AND user_id = ?
|
||||
AND left_at IS NULL
|
||||
');
|
||||
$stmt->execute([$roomId, $userId]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $result && $result['count'] > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les participants d'une room
|
||||
*/
|
||||
private function getRoomParticipants(string $roomId): array {
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT
|
||||
p.user_id,
|
||||
p.is_admin,
|
||||
u.encrypted_name as name,
|
||||
u.first_name
|
||||
FROM chat_participants p
|
||||
INNER JOIN users u ON p.user_id = u.id
|
||||
WHERE p.room_id = ?
|
||||
AND p.left_at IS NULL
|
||||
');
|
||||
$stmt->execute([$roomId]);
|
||||
$participants = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($participants as &$participant) {
|
||||
$participant['name'] = ApiService::decryptData($participant['name']);
|
||||
}
|
||||
|
||||
return $participants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les détails d'une room
|
||||
*/
|
||||
private function getRoomDetails(string $roomId): array {
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT
|
||||
r.id,
|
||||
r.title,
|
||||
r.type,
|
||||
r.created_at,
|
||||
r.created_by,
|
||||
r.updated_at
|
||||
FROM chat_rooms r
|
||||
WHERE r.id = ?
|
||||
');
|
||||
$stmt->execute([$roomId]);
|
||||
$room = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($room) {
|
||||
$room['participants'] = $this->getRoomParticipants($roomId);
|
||||
}
|
||||
|
||||
return $room;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouver une conversation privée existante entre deux utilisateurs
|
||||
*/
|
||||
private function findExistingPrivateRoom(int $user1, int $user2): ?array {
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT r.*
|
||||
FROM chat_rooms r
|
||||
WHERE r.type = "private"
|
||||
AND r.is_active = 1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM chat_participants p1
|
||||
WHERE p1.room_id = r.id
|
||||
AND p1.user_id = ?
|
||||
AND p1.left_at IS NULL
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM chat_participants p2
|
||||
WHERE p2.room_id = r.id
|
||||
AND p2.user_id = ?
|
||||
AND p2.left_at IS NULL
|
||||
)
|
||||
AND (
|
||||
SELECT COUNT(*)
|
||||
FROM chat_participants p
|
||||
WHERE p.room_id = r.id
|
||||
AND p.left_at IS NULL
|
||||
) = 2
|
||||
');
|
||||
$stmt->execute([$user1, $user2]);
|
||||
$room = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($room) {
|
||||
$room['participants'] = $this->getRoomParticipants($room['id']);
|
||||
return $room;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour la date de dernière lecture
|
||||
*/
|
||||
private function updateLastRead(string $roomId, int $userId): void {
|
||||
$stmt = $this->db->prepare('
|
||||
UPDATE chat_participants
|
||||
SET last_read_at = NOW()
|
||||
WHERE room_id = ?
|
||||
AND user_id = ?
|
||||
');
|
||||
$stmt->execute([$roomId, $userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Générer un UUID v4
|
||||
*/
|
||||
private function generateUUID(): string {
|
||||
return sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ use ApiService;
|
||||
require_once __DIR__ . '/../Services/LogService.php';
|
||||
require_once __DIR__ . '/../Services/ApiService.php';
|
||||
require_once __DIR__ . '/EntiteController.php';
|
||||
require_once __DIR__ . '/../Services/Security/SecurityMonitor.php';
|
||||
|
||||
use App\Services\Security\SecurityMonitor;
|
||||
|
||||
class LoginController {
|
||||
private PDO $db;
|
||||
@@ -76,6 +79,11 @@ class LoginController {
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user) {
|
||||
// Enregistrer la tentative échouée
|
||||
$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
|
||||
@@ -88,6 +96,11 @@ class LoginController {
|
||||
$passwordValid = password_verify($data['password'], $user['user_pass_hash']);
|
||||
|
||||
if (!$passwordValid) {
|
||||
// Enregistrer la tentative échouée
|
||||
$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
|
||||
@@ -769,6 +782,88 @@ class LoginController {
|
||||
$response['regions'] = $regionsData;
|
||||
}
|
||||
|
||||
// Ajout des informations du module chat
|
||||
$chatData = [];
|
||||
|
||||
// Récupérer le nombre total de conversations de l'utilisateur
|
||||
$roomCountStmt = $this->db->prepare('
|
||||
SELECT COUNT(DISTINCT r.id) as total_rooms
|
||||
FROM chat_rooms r
|
||||
INNER JOIN chat_participants p ON r.id = p.room_id
|
||||
WHERE p.user_id = :user_id
|
||||
AND p.left_at IS NULL
|
||||
AND r.is_active = 1
|
||||
');
|
||||
$roomCountStmt->execute(['user_id' => $user['id']]);
|
||||
$roomCount = $roomCountStmt->fetch(PDO::FETCH_ASSOC);
|
||||
$chatData['total_rooms'] = (int)($roomCount['total_rooms'] ?? 0);
|
||||
|
||||
// Récupérer le nombre de messages non lus
|
||||
$unreadStmt = $this->db->prepare('
|
||||
SELECT COUNT(*) as unread_count
|
||||
FROM chat_messages m
|
||||
INNER JOIN chat_participants p ON m.room_id = p.room_id
|
||||
WHERE p.user_id = :user_id
|
||||
AND p.left_at IS NULL
|
||||
AND m.sender_id != :sender_id
|
||||
AND m.sent_at > COALESCE(p.last_read_at, p.joined_at)
|
||||
AND m.is_deleted = 0
|
||||
');
|
||||
$unreadStmt->execute([
|
||||
'user_id' => $user['id'],
|
||||
'sender_id' => $user['id']
|
||||
]);
|
||||
$unreadResult = $unreadStmt->fetch(PDO::FETCH_ASSOC);
|
||||
$chatData['unread_messages'] = (int)($unreadResult['unread_count'] ?? 0);
|
||||
|
||||
// Récupérer la dernière conversation active (optionnel, pour affichage rapide)
|
||||
$lastRoomStmt = $this->db->prepare('
|
||||
SELECT
|
||||
r.id,
|
||||
r.title,
|
||||
r.type,
|
||||
(SELECT m.content
|
||||
FROM chat_messages m
|
||||
WHERE m.room_id = r.id
|
||||
AND m.is_deleted = 0
|
||||
ORDER BY m.sent_at DESC
|
||||
LIMIT 1) as last_message,
|
||||
(SELECT m.sent_at
|
||||
FROM chat_messages m
|
||||
WHERE m.room_id = r.id
|
||||
AND m.is_deleted = 0
|
||||
ORDER BY m.sent_at DESC
|
||||
LIMIT 1) as last_message_at
|
||||
FROM chat_rooms r
|
||||
INNER JOIN chat_participants p ON r.id = p.room_id
|
||||
WHERE p.user_id = :user_id
|
||||
AND p.left_at IS NULL
|
||||
AND r.is_active = 1
|
||||
ORDER BY COALESCE(
|
||||
(SELECT MAX(m.sent_at) FROM chat_messages m WHERE m.room_id = r.id),
|
||||
r.created_at
|
||||
) DESC
|
||||
LIMIT 1
|
||||
');
|
||||
$lastRoomStmt->execute(['user_id' => $user['id']]);
|
||||
$lastRoom = $lastRoomStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($lastRoom) {
|
||||
$chatData['last_active_room'] = [
|
||||
'id' => $lastRoom['id'],
|
||||
'title' => $lastRoom['title'],
|
||||
'type' => $lastRoom['type'],
|
||||
'last_message' => $lastRoom['last_message'],
|
||||
'last_message_at' => $lastRoom['last_message_at']
|
||||
];
|
||||
}
|
||||
|
||||
// Indicateur si le chat est disponible pour cet utilisateur
|
||||
$chatData['chat_enabled'] = true; // Peut être conditionné selon le rôle ou l'entité
|
||||
|
||||
// Ajouter les données du chat à la réponse
|
||||
$response['chat'] = $chatData;
|
||||
|
||||
// Envoi de la réponse
|
||||
Response::json($response);
|
||||
} catch (PDOException $e) {
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controllers;
|
||||
|
||||
require_once __DIR__ . '/../Services/LogService.php';
|
||||
require_once __DIR__ . '/../Services/ApiService.php';
|
||||
require_once __DIR__ . '/../Services/ReceiptService.php';
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
@@ -551,10 +552,44 @@ class PassageController {
|
||||
'operationId' => $operationId
|
||||
]);
|
||||
|
||||
// Générer automatiquement un reçu si c'est un don (fk_type = 1) avec email valide
|
||||
$receiptGenerated = false;
|
||||
if (isset($data['fk_type']) && (int)$data['fk_type'] === 1) {
|
||||
// Vérifier si un email a été fourni
|
||||
$hasEmail = false;
|
||||
if (!empty($data['email'])) {
|
||||
$hasEmail = filter_var($data['email'], FILTER_VALIDATE_EMAIL) !== false;
|
||||
} elseif (!empty($encryptedEmail)) {
|
||||
// L'email a déjà été validé lors du chiffrement
|
||||
$hasEmail = true;
|
||||
}
|
||||
|
||||
if ($hasEmail) {
|
||||
try {
|
||||
$receiptService = new \App\Services\ReceiptService();
|
||||
$receiptGenerated = $receiptService->generateReceiptForPassage($passageId);
|
||||
|
||||
if ($receiptGenerated) {
|
||||
LogService::log('Reçu généré automatiquement pour le passage', [
|
||||
'level' => 'info',
|
||||
'passageId' => $passageId
|
||||
]);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
LogService::log('Erreur lors de la génération automatique du reçu', [
|
||||
'level' => 'warning',
|
||||
'error' => $e->getMessage(),
|
||||
'passageId' => $passageId
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'message' => 'Passage créé avec succès',
|
||||
'passage_id' => $passageId
|
||||
'passage_id' => $passageId,
|
||||
'receipt_generated' => $receiptGenerated
|
||||
], 201);
|
||||
} catch (Exception $e) {
|
||||
LogService::log('Erreur lors de la création du passage', [
|
||||
@@ -705,9 +740,52 @@ class PassageController {
|
||||
'passageId' => $passageId
|
||||
]);
|
||||
|
||||
// Vérifier si un reçu doit être généré après la mise à jour
|
||||
$receiptGenerated = false;
|
||||
|
||||
// Récupérer les données actualisées du passage
|
||||
$stmt = $this->db->prepare('SELECT fk_type, encrypted_email, nom_recu FROM ope_pass WHERE id = ?');
|
||||
$stmt->execute([$passageId]);
|
||||
$updatedPassage = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($updatedPassage) {
|
||||
// Générer un reçu si :
|
||||
// - C'est un don (fk_type = 1)
|
||||
// - Il y a un email valide
|
||||
// - Il n'y a pas encore de reçu (nom_recu est vide ou null)
|
||||
if ((int)$updatedPassage['fk_type'] === 1 &&
|
||||
!empty($updatedPassage['encrypted_email']) &&
|
||||
empty($updatedPassage['nom_recu'])) {
|
||||
|
||||
// Vérifier que l'email est valide en le déchiffrant
|
||||
try {
|
||||
$email = ApiService::decryptSearchableData($updatedPassage['encrypted_email']);
|
||||
|
||||
if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$receiptService = new \App\Services\ReceiptService();
|
||||
$receiptGenerated = $receiptService->generateReceiptForPassage($passageId);
|
||||
|
||||
if ($receiptGenerated) {
|
||||
LogService::log('Reçu généré automatiquement après mise à jour du passage', [
|
||||
'level' => 'info',
|
||||
'passageId' => $passageId
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
LogService::log('Erreur lors de la génération automatique du reçu après mise à jour', [
|
||||
'level' => 'warning',
|
||||
'error' => $e->getMessage(),
|
||||
'passageId' => $passageId
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'message' => 'Passage mis à jour avec succès'
|
||||
'message' => 'Passage mis à jour avec succès',
|
||||
'receipt_generated' => $receiptGenerated
|
||||
], 200);
|
||||
} catch (Exception $e) {
|
||||
LogService::log('Erreur lors de la mise à jour du passage', [
|
||||
@@ -800,4 +878,150 @@ class PassageController {
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le reçu PDF d'un passage
|
||||
*
|
||||
* @param string $id ID du passage
|
||||
* @return void
|
||||
*/
|
||||
public function getReceipt(string $id): void {
|
||||
try {
|
||||
$userId = Session::getUserId();
|
||||
if (!$userId) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Vous devez être connecté pour effectuer cette action'
|
||||
], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$passageId = (int)$id;
|
||||
|
||||
// Vérifier que le passage existe et que l'utilisateur y a accès
|
||||
$entiteId = $this->getUserEntiteId($userId);
|
||||
if (!$entiteId) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Entité non trouvée pour cet utilisateur'
|
||||
], 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer les informations du passage et du reçu
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT p.id, p.nom_recu, p.date_creat_recu, p.fk_operation, o.fk_entite
|
||||
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
|
||||
');
|
||||
$stmt->execute([$passageId, $entiteId]);
|
||||
$passage = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$passage) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Passage non trouvé ou accès non autorisé'
|
||||
], 404);
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($passage['nom_recu'])) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Aucun reçu disponible pour ce passage'
|
||||
], 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer le fichier depuis la table medias
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT file_path, mime_type, file_size, fichier
|
||||
FROM medias
|
||||
WHERE support = ? AND support_id = ? AND file_category = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute(['passage', $passageId, 'recu']);
|
||||
$media = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$media) {
|
||||
// Si pas trouvé dans medias, essayer de construire le chemin
|
||||
$filePath = __DIR__ . '/../../uploads/entites/' . $passage['fk_entite'] .
|
||||
'/recus/' . $passage['fk_operation'] . '/' . $passage['nom_recu'];
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Fichier reçu introuvable'
|
||||
], 404);
|
||||
return;
|
||||
}
|
||||
|
||||
$media = [
|
||||
'file_path' => $filePath,
|
||||
'mime_type' => 'application/pdf',
|
||||
'fichier' => $passage['nom_recu'],
|
||||
'file_size' => filesize($filePath)
|
||||
];
|
||||
}
|
||||
|
||||
// Vérifier que le fichier existe
|
||||
if (!file_exists($media['file_path'])) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Fichier reçu introuvable sur le serveur'
|
||||
], 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lire le contenu du fichier
|
||||
$pdfContent = file_get_contents($media['file_path']);
|
||||
if ($pdfContent === false) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Impossible de lire le fichier reçu'
|
||||
], 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Option 1: Retourner le PDF directement (pour téléchargement)
|
||||
if (isset($_GET['download']) && $_GET['download'] === 'true') {
|
||||
header('Content-Type: ' . $media['mime_type']);
|
||||
header('Content-Disposition: attachment; filename="' . $media['fichier'] . '"');
|
||||
header('Content-Length: ' . $media['file_size']);
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
echo $pdfContent;
|
||||
exit;
|
||||
}
|
||||
|
||||
// Option 2: Retourner le PDF en base64 dans JSON (pour Flutter)
|
||||
$base64 = base64_encode($pdfContent);
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'receipt' => [
|
||||
'passage_id' => $passageId,
|
||||
'file_name' => $media['fichier'],
|
||||
'mime_type' => $media['mime_type'],
|
||||
'file_size' => $media['file_size'],
|
||||
'created_at' => $passage['date_creat_recu'],
|
||||
'data_base64' => $base64
|
||||
]
|
||||
], 200);
|
||||
|
||||
} catch (Exception $e) {
|
||||
LogService::log('Erreur lors de la récupération du reçu', [
|
||||
'level' => 'error',
|
||||
'error' => $e->getMessage(),
|
||||
'passageId' => $id,
|
||||
'userId' => $userId ?? null
|
||||
]);
|
||||
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération du reçu'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
251
api/src/Controllers/SecurityController.php
Normal file
251
api/src/Controllers/SecurityController.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
require_once __DIR__ . '/../Services/Security/AlertService.php';
|
||||
require_once __DIR__ . '/../Services/Security/SecurityMonitor.php';
|
||||
require_once __DIR__ . '/../Services/Security/PerformanceMonitor.php';
|
||||
require_once __DIR__ . '/../Services/Security/IPBlocker.php';
|
||||
require_once __DIR__ . '/../Services/Security/EmailThrottler.php';
|
||||
|
||||
use App\Services\Security\AlertService;
|
||||
use App\Services\Security\SecurityMonitor;
|
||||
use App\Services\Security\PerformanceMonitor;
|
||||
use App\Services\Security\IPBlocker;
|
||||
use App\Services\Security\EmailThrottler;
|
||||
use Database;
|
||||
use Session;
|
||||
use Response;
|
||||
use Request;
|
||||
|
||||
class SecurityController {
|
||||
|
||||
/**
|
||||
* Obtenir les métriques de performance
|
||||
* GET /api/admin/metrics
|
||||
*/
|
||||
public function getMetrics(): void {
|
||||
// Vérifier l'authentification et les droits admin
|
||||
if (!Session::isLoggedIn() || Session::getRole() < 2) {
|
||||
Response::json(['error' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$endpoint = Request::getQuery('endpoint');
|
||||
$hours = (int)(Request::getQuery('hours') ?? 24);
|
||||
|
||||
$stats = PerformanceMonitor::getStats($endpoint, $hours);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => $stats
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les alertes actives
|
||||
* GET /api/admin/alerts
|
||||
*/
|
||||
public function getAlerts(): void {
|
||||
// Vérifier l'authentification et les droits admin
|
||||
if (!Session::isLoggedIn() || Session::getRole() < 2) {
|
||||
Response::json(['error' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$limit = (int)(Request::getQuery('limit') ?? 50);
|
||||
$alerts = AlertService::getActiveAlerts($limit);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => $alerts
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Résoudre une alerte
|
||||
* POST /api/admin/alerts/:id/resolve
|
||||
*/
|
||||
public function resolveAlert(string $id): void {
|
||||
// Vérifier l'authentification et les droits admin
|
||||
if (!Session::isLoggedIn() || Session::getRole() < 2) {
|
||||
Response::json(['error' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$data = Request::getJson();
|
||||
$notes = $data['notes'] ?? '';
|
||||
$userId = Session::getUserId();
|
||||
|
||||
$success = AlertService::resolve((int)$id, $userId, $notes);
|
||||
|
||||
Response::json([
|
||||
'success' => $success,
|
||||
'message' => $success ? 'Alerte résolue' : 'Erreur lors de la résolution'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les IPs bloquées
|
||||
* GET /api/admin/blocked-ips
|
||||
*/
|
||||
public function getBlockedIPs(): void {
|
||||
// Vérifier l'authentification et les droits admin
|
||||
if (!Session::isLoggedIn() || Session::getRole() < 2) {
|
||||
Response::json(['error' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$activeOnly = Request::getQuery('active_only') !== 'false';
|
||||
$ips = IPBlocker::getBlockedIPs($activeOnly);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => $ips
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Débloquer une IP
|
||||
* POST /api/admin/unblock-ip
|
||||
*/
|
||||
public function unblockIP(): void {
|
||||
// Vérifier l'authentification et les droits admin
|
||||
if (!Session::isLoggedIn() || Session::getRole() < 2) {
|
||||
Response::json(['error' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$data = Request::getJson();
|
||||
|
||||
if (!isset($data['ip'])) {
|
||||
Response::json(['error' => 'IP address required'], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = Session::getUserId();
|
||||
$success = IPBlocker::unblock($data['ip'], $userId);
|
||||
|
||||
Response::json([
|
||||
'success' => $success,
|
||||
'message' => $success ? 'IP débloquée' : 'Erreur lors du déblocage'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloquer une IP manuellement
|
||||
* POST /api/admin/block-ip
|
||||
*/
|
||||
public function blockIP(): void {
|
||||
// Vérifier l'authentification et les droits admin
|
||||
if (!Session::isLoggedIn() || Session::getRole() < 2) {
|
||||
Response::json(['error' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$data = Request::getJson();
|
||||
|
||||
if (!isset($data['ip'])) {
|
||||
Response::json(['error' => 'IP address required'], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$reason = $data['reason'] ?? 'Blocked by admin';
|
||||
$duration = (int)($data['duration'] ?? 3600);
|
||||
$permanent = $data['permanent'] ?? false;
|
||||
|
||||
if ($permanent) {
|
||||
$success = IPBlocker::blockPermanent($data['ip'], $reason, 'admin_' . Session::getUserId());
|
||||
} else {
|
||||
$success = IPBlocker::block($data['ip'], $duration, $reason, 'admin_' . Session::getUserId());
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'success' => $success,
|
||||
'message' => $success ? 'IP bloquée' : 'Erreur lors du blocage'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir le rapport de sécurité
|
||||
* GET /api/admin/security-report
|
||||
*/
|
||||
public function getSecurityReport(): void {
|
||||
// Vérifier l'authentification et les droits admin
|
||||
if (!Session::isLoggedIn() || Session::getRole() < 2) {
|
||||
Response::json(['error' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compiler le rapport
|
||||
$report = [
|
||||
'security_stats' => SecurityMonitor::getSecurityStats(),
|
||||
'performance_stats' => PerformanceMonitor::getStats(null, 24),
|
||||
'blocked_ips_stats' => IPBlocker::getStats(),
|
||||
'email_throttle_stats' => (new EmailThrottler())->getStats(),
|
||||
'recent_alerts' => AlertService::getActiveAlerts(10)
|
||||
];
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => $report,
|
||||
'generated_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer les anciennes données
|
||||
* POST /api/admin/cleanup
|
||||
*/
|
||||
public function cleanup(): void {
|
||||
// Vérifier l'authentification et les droits super admin
|
||||
if (!Session::isLoggedIn() || Session::getRole() < 9) {
|
||||
Response::json(['error' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
$data = Request::getJson();
|
||||
$daysToKeep = (int)($data['days_to_keep'] ?? 30);
|
||||
|
||||
// Nettoyer les métriques de performance
|
||||
$cleanedMetrics = PerformanceMonitor::cleanup($daysToKeep);
|
||||
|
||||
// Nettoyer les IPs expirées
|
||||
$cleanedIPs = IPBlocker::cleanupExpired();
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'message' => 'Nettoyage effectué',
|
||||
'cleaned' => [
|
||||
'performance_metrics' => $cleanedMetrics,
|
||||
'expired_ips' => $cleanedIPs
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tester les alertes email
|
||||
* POST /api/admin/test-alert
|
||||
*/
|
||||
public function testAlert(): void {
|
||||
// Vérifier l'authentification et les droits super admin
|
||||
if (!Session::isLoggedIn() || Session::getRole() < 9) {
|
||||
Response::json(['error' => 'Unauthorized'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
// Déclencher une alerte de test
|
||||
AlertService::trigger('TEST_ALERT', [
|
||||
'message' => 'Ceci est une alerte de test déclenchée manuellement',
|
||||
'triggered_by' => Session::getUsername(),
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
], 'INFO');
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'message' => 'Alerte de test envoyée'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -214,11 +214,41 @@ class UserController {
|
||||
$data = Request::getJson();
|
||||
$currentUserId = Session::getUserId();
|
||||
|
||||
// Log de début de création avec les données reçues (sans données sensibles)
|
||||
LogService::log('Tentative de création d\'utilisateur', [
|
||||
'level' => 'debug',
|
||||
'createdBy' => $currentUserId,
|
||||
'fields_received' => array_keys($data ?? []),
|
||||
'has_email' => isset($data['email']),
|
||||
'has_name' => isset($data['name']),
|
||||
'has_username' => isset($data['username']),
|
||||
'has_password' => isset($data['password'])
|
||||
]);
|
||||
|
||||
// Validation des données requises
|
||||
if (!isset($data['email'], $data['name'])) {
|
||||
if (!isset($data['email']) || empty(trim($data['email']))) {
|
||||
LogService::log('Erreur création utilisateur : Email manquant', [
|
||||
'level' => 'warning',
|
||||
'createdBy' => $currentUserId
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Email et nom requis'
|
||||
'message' => 'Email requis',
|
||||
'field' => 'email'
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isset($data['name']) || empty(trim($data['name']))) {
|
||||
LogService::log('Erreur création utilisateur : Nom manquant', [
|
||||
'level' => 'warning',
|
||||
'createdBy' => $currentUserId,
|
||||
'email' => $data['email'] ?? 'non fourni'
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Nom requis',
|
||||
'field' => 'name'
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
@@ -260,9 +290,16 @@ class UserController {
|
||||
|
||||
// Validation de l'email
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
LogService::log('Erreur création utilisateur : Format email invalide', [
|
||||
'level' => 'warning',
|
||||
'createdBy' => $currentUserId,
|
||||
'email' => $email
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Format d\'email invalide'
|
||||
'message' => 'Format d\'email invalide',
|
||||
'field' => 'email',
|
||||
'value' => $email
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
@@ -290,20 +327,56 @@ class UserController {
|
||||
if ($chkUsernameManuel === 1) {
|
||||
// Username manuel obligatoire
|
||||
if (!isset($data['username']) || empty(trim($data['username']))) {
|
||||
LogService::log('Erreur création utilisateur : Username manuel requis mais non fourni', [
|
||||
'level' => 'warning',
|
||||
'createdBy' => $currentUserId,
|
||||
'email' => $email,
|
||||
'entite_id' => $entiteId,
|
||||
'chk_username_manuel' => $chkUsernameManuel
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Le nom d\'utilisateur est requis pour cette entité'
|
||||
'message' => 'Identifiant requis',
|
||||
'field' => 'username',
|
||||
'details' => 'Saisie manuelle obligatoire pour cette entité'
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$username = trim(strtolower($data['username']));
|
||||
// Trim du username mais on garde la casse originale (plus de lowercase forcé)
|
||||
$username = trim($data['username']);
|
||||
|
||||
// Validation du format du username
|
||||
if (!preg_match('/^[a-z][a-z0-9._-]{9,29}$/', $username)) {
|
||||
// Validation ultra-souple : seulement la longueur en caractères UTF-8
|
||||
$usernameLength = mb_strlen($username, 'UTF-8');
|
||||
|
||||
if ($usernameLength < 8) {
|
||||
LogService::log('Erreur création utilisateur : Username trop court', [
|
||||
'level' => 'warning',
|
||||
'createdBy' => $currentUserId,
|
||||
'email' => $email,
|
||||
'username_length' => $usernameLength
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Format du nom d\'utilisateur invalide (10-30 caractères, commence par une lettre, caractères autorisés: a-z, 0-9, ., -, _)'
|
||||
'message' => 'Identifiant trop court',
|
||||
'field' => 'username',
|
||||
'details' => 'Minimum 8 caractères'
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($usernameLength > 30) {
|
||||
LogService::log('Erreur création utilisateur : Username trop long', [
|
||||
'level' => 'warning',
|
||||
'createdBy' => $currentUserId,
|
||||
'email' => $email,
|
||||
'username_length' => $usernameLength
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Identifiant trop long',
|
||||
'field' => 'username',
|
||||
'details' => 'Maximum 30 caractères'
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
@@ -316,7 +389,8 @@ class UserController {
|
||||
if ($checkUsernameStmt->fetch()) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Ce nom d\'utilisateur est déjà utilisé dans GeoSector'
|
||||
'message' => 'Identifiant déjà utilisé',
|
||||
'field' => 'username'
|
||||
], 409);
|
||||
return;
|
||||
}
|
||||
@@ -338,9 +412,18 @@ class UserController {
|
||||
if ($chkMdpManuel === 1) {
|
||||
// Mot de passe manuel obligatoire
|
||||
if (!isset($data['password']) || empty($data['password'])) {
|
||||
LogService::log('Erreur création utilisateur : Mot de passe manuel requis mais non fourni', [
|
||||
'level' => 'warning',
|
||||
'createdBy' => $currentUserId,
|
||||
'email' => $email,
|
||||
'entite_id' => $entiteId,
|
||||
'chk_mdp_manuel' => $chkMdpManuel
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Le mot de passe est requis pour cette entité'
|
||||
'message' => 'Mot de passe requis',
|
||||
'field' => 'password',
|
||||
'details' => 'Saisie manuelle obligatoire pour cette entité'
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
@@ -927,22 +1010,60 @@ class UserController {
|
||||
try {
|
||||
$data = Request::getJson();
|
||||
|
||||
// Log de la requête
|
||||
LogService::log('Vérification de disponibilité username', [
|
||||
'level' => 'debug',
|
||||
'checkedBy' => Session::getUserId(),
|
||||
'has_username' => isset($data['username'])
|
||||
]);
|
||||
|
||||
// Validation de la présence du username
|
||||
if (!isset($data['username']) || empty(trim($data['username']))) {
|
||||
LogService::log('Erreur vérification username : Username manquant', [
|
||||
'level' => 'warning',
|
||||
'checkedBy' => Session::getUserId()
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Username requis pour la vérification'
|
||||
'message' => 'Identifiant requis',
|
||||
'field' => 'username'
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$username = trim(strtolower($data['username']));
|
||||
// Trim du username mais on garde la casse originale
|
||||
$username = trim($data['username']);
|
||||
|
||||
// Validation du format du username
|
||||
if (!preg_match('/^[a-z][a-z0-9._-]{9,29}$/', $username)) {
|
||||
// Validation ultra-souple : seulement la longueur en caractères UTF-8
|
||||
$usernameLength = mb_strlen($username, 'UTF-8');
|
||||
|
||||
if ($usernameLength < 8) {
|
||||
LogService::log('Erreur vérification username : Username trop court', [
|
||||
'level' => 'warning',
|
||||
'checkedBy' => Session::getUserId(),
|
||||
'username_length' => $usernameLength
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Format invalide : 10-30 caractères, commence par une lettre, caractères autorisés: a-z, 0-9, ., -, _',
|
||||
'message' => 'Identifiant trop court',
|
||||
'field' => 'username',
|
||||
'details' => 'Minimum 8 caractères',
|
||||
'available' => false
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($usernameLength > 30) {
|
||||
LogService::log('Erreur vérification username : Username trop long', [
|
||||
'level' => 'warning',
|
||||
'checkedBy' => Session::getUserId(),
|
||||
'username_length' => $usernameLength
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Identifiant trop long',
|
||||
'field' => 'username',
|
||||
'details' => 'Maximum 30 caractères',
|
||||
'available' => false
|
||||
], 400);
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/MonitoredDatabase.php';
|
||||
require_once __DIR__ . '/../Services/Security/AlertService.php';
|
||||
|
||||
use App\Services\Security\AlertService;
|
||||
|
||||
class Database {
|
||||
private static ?PDO $instance = null;
|
||||
private static array $config;
|
||||
@@ -23,13 +28,22 @@ class Database {
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
|
||||
self::$instance = new PDO(
|
||||
// Utiliser MonitoredDatabase pour le monitoring
|
||||
self::$instance = new MonitoredDatabase(
|
||||
$dsn,
|
||||
self::$config['username'],
|
||||
self::$config['password'],
|
||||
$options
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
// Créer une alerte pour la connexion échouée
|
||||
AlertService::trigger('DB_CONNECTION', [
|
||||
'error' => $e->getMessage(),
|
||||
'host' => self::$config['host'],
|
||||
'database' => self::$config['name'],
|
||||
'message' => 'Échec de connexion à la base de données'
|
||||
], 'CRITICAL');
|
||||
|
||||
throw new RuntimeException("Database connection failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
182
api/src/Core/MonitoredDatabase.php
Normal file
182
api/src/Core/MonitoredDatabase.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../Services/Security/PerformanceMonitor.php';
|
||||
require_once __DIR__ . '/../Services/Security/SecurityMonitor.php';
|
||||
|
||||
use App\Services\Security\PerformanceMonitor;
|
||||
use App\Services\Security\SecurityMonitor;
|
||||
|
||||
/**
|
||||
* Classe PDO étendue avec monitoring de sécurité et performance
|
||||
*/
|
||||
class MonitoredDatabase extends PDO {
|
||||
|
||||
/**
|
||||
* Préparer une requête avec monitoring
|
||||
*/
|
||||
public function prepare($statement, $options = []): PDOStatement|false {
|
||||
// Démarrer le chronométrage
|
||||
PerformanceMonitor::startDbQuery($statement);
|
||||
|
||||
try {
|
||||
$stmt = parent::prepare($statement, $options);
|
||||
|
||||
// Retourner un statement monitored
|
||||
if ($stmt !== false) {
|
||||
return new MonitoredStatement($stmt, $statement);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (PDOException $e) {
|
||||
// Terminer le chronométrage
|
||||
PerformanceMonitor::endDbQuery();
|
||||
|
||||
// Analyser l'erreur SQL
|
||||
SecurityMonitor::analyzeSQLError($e, $statement);
|
||||
|
||||
// Re-lancer l'exception
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécuter une requête directement avec monitoring
|
||||
*/
|
||||
public function exec($statement): int|false {
|
||||
// Démarrer le chronométrage
|
||||
PerformanceMonitor::startDbQuery($statement);
|
||||
|
||||
try {
|
||||
$result = parent::exec($statement);
|
||||
|
||||
// Terminer le chronométrage
|
||||
PerformanceMonitor::endDbQuery();
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (PDOException $e) {
|
||||
// Terminer le chronométrage
|
||||
PerformanceMonitor::endDbQuery();
|
||||
|
||||
// Analyser l'erreur SQL
|
||||
SecurityMonitor::analyzeSQLError($e, $statement);
|
||||
|
||||
// Re-lancer l'exception
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query avec monitoring
|
||||
*/
|
||||
public function query($statement, $mode = PDO::ATTR_DEFAULT_FETCH_MODE, ...$args): PDOStatement|false {
|
||||
// Démarrer le chronométrage
|
||||
PerformanceMonitor::startDbQuery($statement);
|
||||
|
||||
try {
|
||||
$result = parent::query($statement, $mode, ...$args);
|
||||
|
||||
// Terminer le chronométrage
|
||||
PerformanceMonitor::endDbQuery();
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (PDOException $e) {
|
||||
// Terminer le chronométrage
|
||||
PerformanceMonitor::endDbQuery();
|
||||
|
||||
// Analyser l'erreur SQL
|
||||
SecurityMonitor::analyzeSQLError($e, $statement);
|
||||
|
||||
// Re-lancer l'exception
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PDOStatement étendu avec monitoring
|
||||
*/
|
||||
class MonitoredStatement extends PDOStatement {
|
||||
|
||||
private PDOStatement $stmt;
|
||||
private string $query;
|
||||
|
||||
public function __construct(PDOStatement $stmt, string $query) {
|
||||
$this->stmt = $stmt;
|
||||
$this->query = $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécuter avec monitoring
|
||||
*/
|
||||
public function execute($params = null): bool {
|
||||
// Démarrer le chronométrage (si pas déjà fait dans prepare)
|
||||
PerformanceMonitor::startDbQuery($this->query);
|
||||
|
||||
try {
|
||||
$result = $this->stmt->execute($params);
|
||||
|
||||
// Terminer le chronométrage
|
||||
PerformanceMonitor::endDbQuery();
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (PDOException $e) {
|
||||
// Terminer le chronométrage
|
||||
PerformanceMonitor::endDbQuery();
|
||||
|
||||
// Analyser l'erreur SQL
|
||||
SecurityMonitor::analyzeSQLError($e, $this->query);
|
||||
|
||||
// Re-lancer l'exception
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy vers le statement original pour toutes les autres méthodes
|
||||
*/
|
||||
public function __call($method, $args) {
|
||||
return call_user_func_array([$this->stmt, $method], $args);
|
||||
}
|
||||
|
||||
public function fetch($mode = PDO::FETCH_DEFAULT, $cursorOrientation = PDO::FETCH_ORI_NEXT, $cursorOffset = 0): mixed {
|
||||
return $this->stmt->fetch($mode, $cursorOrientation, $cursorOffset);
|
||||
}
|
||||
|
||||
public function fetchAll($mode = PDO::FETCH_DEFAULT, ...$args): array {
|
||||
return $this->stmt->fetchAll($mode, ...$args);
|
||||
}
|
||||
|
||||
public function fetchColumn($column = 0): mixed {
|
||||
return $this->stmt->fetchColumn($column);
|
||||
}
|
||||
|
||||
public function rowCount(): int {
|
||||
return $this->stmt->rowCount();
|
||||
}
|
||||
|
||||
public function bindParam($param, &$var, $type = PDO::PARAM_STR, $maxLength = null, $driverOptions = null): bool {
|
||||
return $this->stmt->bindParam($param, $var, $type, $maxLength, $driverOptions);
|
||||
}
|
||||
|
||||
public function bindValue($param, $value, $type = PDO::PARAM_STR): bool {
|
||||
return $this->stmt->bindValue($param, $value, $type);
|
||||
}
|
||||
|
||||
public function closeCursor(): bool {
|
||||
return $this->stmt->closeCursor();
|
||||
}
|
||||
|
||||
public function errorCode(): ?string {
|
||||
return $this->stmt->errorCode();
|
||||
}
|
||||
|
||||
public function errorInfo(): array {
|
||||
return $this->stmt->errorInfo();
|
||||
}
|
||||
}
|
||||
@@ -34,13 +34,14 @@ class Router {
|
||||
$this->post('log', ['LogController', 'index']);
|
||||
|
||||
// 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
|
||||
$this->get('users', ['UserController', 'getUsers']);
|
||||
$this->get('users/:id', ['UserController', 'getUserById']);
|
||||
$this->post('users', ['UserController', 'createUser']);
|
||||
$this->put('users/:id', ['UserController', 'updateUser']);
|
||||
$this->delete('users/:id', ['UserController', 'deleteUser']);
|
||||
$this->post('users/:id/reset-password', ['UserController', 'resetPassword']);
|
||||
$this->post('users/check-username', ['UserController', 'checkUsername']);
|
||||
$this->post('logout', ['LoginController', 'logout']);
|
||||
|
||||
// Routes entités
|
||||
@@ -69,6 +70,7 @@ class Router {
|
||||
// Routes passages
|
||||
$this->get('passages', ['PassageController', 'getPassages']);
|
||||
$this->get('passages/:id', ['PassageController', 'getPassageById']);
|
||||
$this->get('passages/:id/receipt', ['PassageController', 'getReceipt']);
|
||||
$this->get('passages/operation/:operation_id', ['PassageController', 'getPassagesByOperation']);
|
||||
$this->post('passages', ['PassageController', 'createPassage']);
|
||||
$this->put('passages/:id', ['PassageController', 'updatePassage']);
|
||||
@@ -97,6 +99,25 @@ class Router {
|
||||
$this->post('password/check', ['PasswordController', 'checkStrength']);
|
||||
$this->post('password/compromised', ['PasswordController', 'checkCompromised']);
|
||||
$this->get('password/generate', ['PasswordController', 'generate']);
|
||||
|
||||
// Routes du module Chat
|
||||
$this->get('chat/rooms', ['ChatController', 'getRooms']);
|
||||
$this->post('chat/rooms', ['ChatController', 'createRoom']);
|
||||
$this->get('chat/rooms/:id/messages', ['ChatController', 'getRoomMessages']);
|
||||
$this->post('chat/rooms/:id/messages', ['ChatController', 'sendMessage']);
|
||||
$this->post('chat/rooms/:id/read', ['ChatController', 'markAsRead']);
|
||||
$this->get('chat/recipients', ['ChatController', 'getRecipients']);
|
||||
|
||||
// Routes du module Sécurité (Admin uniquement)
|
||||
$this->get('admin/metrics', ['SecurityController', 'getMetrics']);
|
||||
$this->get('admin/alerts', ['SecurityController', 'getAlerts']);
|
||||
$this->post('admin/alerts/:id/resolve', ['SecurityController', 'resolveAlert']);
|
||||
$this->get('admin/blocked-ips', ['SecurityController', 'getBlockedIPs']);
|
||||
$this->post('admin/unblock-ip', ['SecurityController', 'unblockIP']);
|
||||
$this->post('admin/block-ip', ['SecurityController', 'blockIP']);
|
||||
$this->get('admin/security-report', ['SecurityController', 'getSecurityReport']);
|
||||
$this->post('admin/cleanup', ['SecurityController', 'cleanup']);
|
||||
$this->post('admin/test-alert', ['SecurityController', 'testAlert']);
|
||||
}
|
||||
|
||||
public function handle(): void {
|
||||
|
||||
622
api/src/Services/ReceiptService.php
Normal file
622
api/src/Services/ReceiptService.php
Normal file
@@ -0,0 +1,622 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
require_once __DIR__ . '/LogService.php';
|
||||
require_once __DIR__ . '/ApiService.php';
|
||||
require_once __DIR__ . '/FileService.php';
|
||||
|
||||
use PDO;
|
||||
use Database;
|
||||
use LogService;
|
||||
use ApiService;
|
||||
use FileService;
|
||||
use Exception;
|
||||
use DateTime;
|
||||
|
||||
/**
|
||||
* Service de gestion des reçus pour les passages de type don (fk_type=1)
|
||||
* Optimisé pour générer des PDF très légers (< 20KB)
|
||||
*/
|
||||
class ReceiptService {
|
||||
private PDO $db;
|
||||
private FileService $fileService;
|
||||
private const DEFAULT_LOGO_PATH = __DIR__ . '/../../docs/_logo_recu.png';
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
$this->fileService = new FileService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un reçu pour un passage de type don avec email valide
|
||||
*
|
||||
* @param int $passageId ID du passage
|
||||
* @return bool True si le reçu a été généré avec succès
|
||||
*/
|
||||
public function generateReceiptForPassage(int $passageId): bool {
|
||||
try {
|
||||
// Récupérer les données du passage
|
||||
$passageData = $this->getPassageData($passageId);
|
||||
if (!$passageData) {
|
||||
LogService::log('Passage non trouvé pour génération de reçu', [
|
||||
'level' => 'warning',
|
||||
'passageId' => $passageId
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier que c'est un don effectué (fk_type = 1) avec email valide
|
||||
if ((int)$passageData['fk_type'] !== 1) {
|
||||
return false; // Pas un don, pas de reçu
|
||||
}
|
||||
|
||||
// Déchiffrer et vérifier l'email
|
||||
$email = '';
|
||||
if (!empty($passageData['encrypted_email'])) {
|
||||
$email = ApiService::decryptSearchableData($passageData['encrypted_email']);
|
||||
}
|
||||
|
||||
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
LogService::log('Email invalide ou manquant pour le reçu', [
|
||||
'level' => 'info',
|
||||
'passageId' => $passageId
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Récupérer les données de l'opération
|
||||
$operationData = $this->getOperationData($passageData['fk_operation']);
|
||||
if (!$operationData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Récupérer les données de l'entité
|
||||
$entiteData = $this->getEntiteData($operationData['fk_entite']);
|
||||
if (!$entiteData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Récupérer le logo de l'entité
|
||||
$logoPath = $this->getEntiteLogo($operationData['fk_entite']);
|
||||
|
||||
// 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 = "/entites/{$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');
|
||||
}
|
||||
|
||||
// Appliquer les permissions
|
||||
$this->fileService->setFilePermissions($filePath);
|
||||
|
||||
// Enregistrer dans la table medias
|
||||
$mediaId = $this->saveToMedias(
|
||||
$operationData['fk_entite'],
|
||||
$operationData['id'],
|
||||
$passageId,
|
||||
$fileName,
|
||||
$filePath,
|
||||
strlen($pdfContent)
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
LogService::log('Reçu généré avec succès', [
|
||||
'level' => 'info',
|
||||
'passageId' => $passageId,
|
||||
'mediaId' => $mediaId,
|
||||
'fileName' => $fileName,
|
||||
'fileSize' => strlen($pdfContent)
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
LogService::log('Erreur lors de la génération du reçu', [
|
||||
'level' => 'error',
|
||||
'error' => $e->getMessage(),
|
||||
'passageId' => $passageId
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un PDF ultra-optimisé (< 20KB)
|
||||
* Utilise le format PDF natif pour minimiser la taille
|
||||
*/
|
||||
private function generateOptimizedPDF(array $data, ?string $logoPath): string {
|
||||
// Début du PDF
|
||||
$pdf = "%PDF-1.3\n";
|
||||
$objects = [];
|
||||
$xref = [];
|
||||
|
||||
// Object 1 - Catalog
|
||||
$objects[1] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
|
||||
|
||||
// Object 2 - Pages
|
||||
$objects[2] = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n";
|
||||
|
||||
// Object 3 - Page
|
||||
$objects[3] = "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n";
|
||||
|
||||
// Object 4 - Font (Helvetica)
|
||||
$objects[4] = "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n";
|
||||
|
||||
// Contenu de la page (texte du reçu)
|
||||
$content = $this->generatePDFContent($data);
|
||||
|
||||
// Object 5 - Content stream
|
||||
$contentLength = strlen($content);
|
||||
$objects[5] = "5 0 obj\n<< /Length $contentLength >>\nstream\n$content\nendstream\nendobj\n";
|
||||
|
||||
// Construction du PDF final
|
||||
$offset = strlen($pdf);
|
||||
foreach ($objects as $obj) {
|
||||
$xref[] = $offset;
|
||||
$pdf .= $obj;
|
||||
$offset += strlen($obj);
|
||||
}
|
||||
|
||||
// Table xref
|
||||
$pdf .= "xref\n";
|
||||
$pdf .= "0 " . (count($objects) + 1) . "\n";
|
||||
$pdf .= "0000000000 65535 f \n";
|
||||
foreach ($xref as $off) {
|
||||
$pdf .= sprintf("%010d 00000 n \n", $off);
|
||||
}
|
||||
|
||||
// Trailer
|
||||
$pdf .= "trailer\n";
|
||||
$pdf .= "<< /Size " . (count($objects) + 1) . " /Root 1 0 R >>\n";
|
||||
$pdf .= "startxref\n";
|
||||
$pdf .= "$offset\n";
|
||||
$pdf .= "%%EOF\n";
|
||||
|
||||
return $pdf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le contenu textuel du reçu pour le PDF
|
||||
*/
|
||||
private function generatePDFContent(array $data): string {
|
||||
$content = "BT\n";
|
||||
$content .= "/F1 12 Tf\n";
|
||||
$y = 750;
|
||||
|
||||
// En-tête
|
||||
$content .= "50 $y Td\n";
|
||||
$content .= "(" . $this->escapeString($data['entite_name']) . ") Tj\n";
|
||||
$y -= 20;
|
||||
|
||||
if (!empty($data['entite_address'])) {
|
||||
$content .= "0 -20 Td\n";
|
||||
$content .= "(" . $this->escapeString($data['entite_address']) . ") Tj\n";
|
||||
$y -= 20;
|
||||
}
|
||||
|
||||
// Titre du reçu
|
||||
$y -= 40;
|
||||
$content .= "/F1 16 Tf\n";
|
||||
$content .= "0 -40 Td\n";
|
||||
$content .= "(RECU DE DON N° " . $data['receipt_number'] . ") Tj\n";
|
||||
|
||||
$content .= "/F1 10 Tf\n";
|
||||
$content .= "0 -15 Td\n";
|
||||
$content .= "(Article 200 du Code General des Impots) Tj\n";
|
||||
|
||||
// Informations du donateur
|
||||
$y -= 60;
|
||||
$content .= "/F1 12 Tf\n";
|
||||
$content .= "0 -45 Td\n";
|
||||
$content .= "(DONATEUR) Tj\n";
|
||||
|
||||
$content .= "/F1 11 Tf\n";
|
||||
$content .= "0 -20 Td\n";
|
||||
$content .= "(Nom : " . $this->escapeString($data['donor_name']) . ") Tj\n";
|
||||
|
||||
if (!empty($data['donor_address'])) {
|
||||
$content .= "0 -15 Td\n";
|
||||
$content .= "(Adresse : " . $this->escapeString($data['donor_address']) . ") Tj\n";
|
||||
}
|
||||
|
||||
if (!empty($data['donor_email'])) {
|
||||
$content .= "0 -15 Td\n";
|
||||
$content .= "(Email : " . $this->escapeString($data['donor_email']) . ") Tj\n";
|
||||
}
|
||||
|
||||
// Détails du don
|
||||
$content .= "0 -30 Td\n";
|
||||
$content .= "/F1 12 Tf\n";
|
||||
$content .= "(DETAILS DU DON) Tj\n";
|
||||
|
||||
$content .= "/F1 11 Tf\n";
|
||||
$content .= "0 -20 Td\n";
|
||||
$content .= "(Date : " . $data['donation_date'] . ") Tj\n";
|
||||
|
||||
$content .= "0 -15 Td\n";
|
||||
$content .= "(Montant : " . $data['amount'] . " EUR) Tj\n";
|
||||
|
||||
$content .= "0 -15 Td\n";
|
||||
$content .= "(Mode de reglement : " . $this->escapeString($data['payment_method']) . ") Tj\n";
|
||||
|
||||
if (!empty($data['operation_name'])) {
|
||||
$content .= "0 -15 Td\n";
|
||||
$content .= "(Campagne : " . $this->escapeString($data['operation_name']) . ") Tj\n";
|
||||
}
|
||||
|
||||
// Mention légale
|
||||
$content .= "/F1 9 Tf\n";
|
||||
$content .= "0 -40 Td\n";
|
||||
$content .= "(Reduction d'impot egale a 66% du montant verse dans la limite de 20% du revenu imposable) Tj\n";
|
||||
|
||||
// Date et signature
|
||||
$content .= "/F1 11 Tf\n";
|
||||
$content .= "0 -30 Td\n";
|
||||
$content .= "(Fait a " . $this->escapeString($data['entite_city']) . ", le " . $data['signature_date'] . ") Tj\n";
|
||||
|
||||
$content .= "0 -20 Td\n";
|
||||
$content .= "(Le President) Tj\n";
|
||||
|
||||
$content .= "ET\n";
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Échappe les caractères spéciaux pour le PDF
|
||||
*/
|
||||
private function escapeString(string $str): string {
|
||||
// Échapper les caractères spéciaux PDF
|
||||
$str = str_replace('\\', '\\\\', $str);
|
||||
$str = str_replace('(', '\\(', $str);
|
||||
$str = str_replace(')', '\\)', $str);
|
||||
|
||||
// Remplacer manuellement les caractères accentués les plus courants
|
||||
$accents = [
|
||||
'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'A',
|
||||
'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a',
|
||||
'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E',
|
||||
'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e',
|
||||
'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I',
|
||||
'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i',
|
||||
'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O',
|
||||
'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
|
||||
'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U',
|
||||
'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u',
|
||||
'Ñ' => 'N', 'ñ' => 'n',
|
||||
'Ç' => 'C', 'ç' => 'c',
|
||||
'Œ' => 'OE', 'œ' => 'oe',
|
||||
'Æ' => 'AE', 'æ' => 'ae'
|
||||
];
|
||||
|
||||
$str = strtr($str, $accents);
|
||||
|
||||
// Supprimer tout caractère non-ASCII restant
|
||||
$str = preg_replace('/[^\x20-\x7E]/', '', $str);
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les données du passage
|
||||
*/
|
||||
private function getPassageData(int $passageId): ?array {
|
||||
$stmt = $this->db->prepare('
|
||||
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
|
||||
WHERE p.id = ? AND p.chk_active = 1
|
||||
');
|
||||
$stmt->execute([$passageId]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les données de l'opération
|
||||
*/
|
||||
private function getOperationData(int $operationId): ?array {
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT * FROM operations
|
||||
WHERE id = ? AND chk_active = 1
|
||||
');
|
||||
$stmt->execute([$operationId]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les données de l'entité
|
||||
*/
|
||||
private function getEntiteData(int $entiteId): ?array {
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT * FROM entites
|
||||
WHERE id = ? AND chk_active = 1
|
||||
');
|
||||
$stmt->execute([$entiteId]);
|
||||
$entite = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($entite) {
|
||||
// Déchiffrer les données
|
||||
if (!empty($entite['encrypted_name'])) {
|
||||
$entite['name'] = ApiService::decryptData($entite['encrypted_name']);
|
||||
}
|
||||
if (!empty($entite['encrypted_email'])) {
|
||||
$entite['email'] = ApiService::decryptSearchableData($entite['encrypted_email']);
|
||||
}
|
||||
if (!empty($entite['encrypted_phone'])) {
|
||||
$entite['phone'] = ApiService::decryptData($entite['encrypted_phone']);
|
||||
}
|
||||
}
|
||||
|
||||
return $entite ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le chemin du logo de l'entité
|
||||
*/
|
||||
private function getEntiteLogo(int $entiteId): ?string {
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT file_path FROM medias
|
||||
WHERE support = ? AND support_id = ? AND file_category = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute(['entite', $entiteId, 'logo']);
|
||||
$logo = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($logo && !empty($logo['file_path']) && file_exists($logo['file_path'])) {
|
||||
return $logo['file_path'];
|
||||
}
|
||||
|
||||
// Utiliser le logo par défaut si disponible
|
||||
if (file_exists(self::DEFAULT_LOGO_PATH)) {
|
||||
return self::DEFAULT_LOGO_PATH;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare les données pour le reçu
|
||||
*/
|
||||
private function prepareReceiptData(array $passage, array $operation, array $entite, string $email): array {
|
||||
// Déchiffrer le nom du donateur
|
||||
$donorName = '';
|
||||
if (!empty($passage['encrypted_name'])) {
|
||||
$donorName = ApiService::decryptData($passage['encrypted_name']);
|
||||
} elseif (!empty($passage['user_encrypted_name'])) {
|
||||
$donorName = ApiService::decryptData($passage['user_encrypted_name']);
|
||||
}
|
||||
|
||||
// Construire l'adresse du donateur
|
||||
$donorAddress = [];
|
||||
if (!empty($passage['numero'])) $donorAddress[] = $passage['numero'];
|
||||
if (!empty($passage['rue'])) $donorAddress[] = $passage['rue'];
|
||||
if (!empty($passage['rue_bis'])) $donorAddress[] = $passage['rue_bis'];
|
||||
if (!empty($passage['ville'])) $donorAddress[] = $passage['ville'];
|
||||
|
||||
// Date du don
|
||||
$donationDate = '';
|
||||
if (!empty($passage['passed_at'])) {
|
||||
$donationDate = date('d/m/Y', strtotime($passage['passed_at']));
|
||||
} elseif (!empty($passage['created_at'])) {
|
||||
$donationDate = date('d/m/Y', strtotime($passage['created_at']));
|
||||
}
|
||||
|
||||
// Mode de règlement
|
||||
$paymentMethod = $this->getPaymentMethodLabel((int)($passage['fk_type_reglement'] ?? 1));
|
||||
|
||||
// Adresse de l'entité
|
||||
$entiteAddress = [];
|
||||
if (!empty($entite['adresse1'])) $entiteAddress[] = $entite['adresse1'];
|
||||
if (!empty($entite['adresse2'])) $entiteAddress[] = $entite['adresse2'];
|
||||
if (!empty($entite['code_postal']) || !empty($entite['ville'])) {
|
||||
$entiteAddress[] = trim($entite['code_postal'] . ' ' . $entite['ville']);
|
||||
}
|
||||
|
||||
return [
|
||||
'receipt_number' => $passage['id'],
|
||||
'entite_name' => $entite['name'] ?? 'Amicale des Sapeurs-Pompiers',
|
||||
'entite_address' => implode(' ', $entiteAddress),
|
||||
'entite_city' => $entite['ville'] ?? '',
|
||||
'entite_email' => $entite['email'] ?? '',
|
||||
'entite_phone' => $entite['phone'] ?? '',
|
||||
'donor_name' => $donorName,
|
||||
'donor_address' => implode(' ', $donorAddress),
|
||||
'donor_email' => $email,
|
||||
'donation_date' => $donationDate,
|
||||
'amount' => number_format((float)($passage['montant'] ?? 0), 2, ',', ' '),
|
||||
'payment_method' => $paymentMethod,
|
||||
'operation_name' => $operation['libelle'] ?? '',
|
||||
'signature_date' => date('d/m/Y')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le libellé du mode de règlement
|
||||
*/
|
||||
private function getPaymentMethodLabel(int $typeReglement): string {
|
||||
$stmt = $this->db->prepare('SELECT libelle FROM x_types_reglements WHERE id = ?');
|
||||
$stmt->execute([$typeReglement]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $result ? $result['libelle'] : 'Espèces';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le fichier dans la table medias
|
||||
*/
|
||||
private function saveToMedias(int $entiteId, int $operationId, int $passageId, string $fileName, string $filePath, int $fileSize): int {
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO medias (
|
||||
support, support_id, fichier, file_type, file_category,
|
||||
file_size, mime_type, original_name, fk_entite, fk_operation,
|
||||
file_path, description, created_at, fk_user_creat
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'passage', // support
|
||||
$passageId, // support_id
|
||||
$fileName, // fichier
|
||||
'pdf', // file_type
|
||||
'recu', // file_category
|
||||
$fileSize, // file_size
|
||||
'application/pdf', // mime_type
|
||||
$fileName, // original_name
|
||||
$entiteId, // fk_entite
|
||||
$operationId, // fk_operation
|
||||
$filePath, // file_path
|
||||
'Reçu de don', // description
|
||||
0 // fk_user_creat (système)
|
||||
]);
|
||||
|
||||
return (int)$this->db->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le passage avec les informations du reçu
|
||||
*/
|
||||
private function updatePassageReceipt(int $passageId, string $fileName): void {
|
||||
$stmt = $this->db->prepare('
|
||||
UPDATE ope_pass
|
||||
SET nom_recu = ?, date_creat_recu = NOW()
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$fileName, $passageId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute le reçu à la queue d'email
|
||||
*/
|
||||
private function queueReceiptEmail(int $passageId, string $email, array $receiptData, string $pdfContent): void {
|
||||
// Préparer le sujet
|
||||
$subject = "Votre reçu de don N°" . $receiptData['receipt_number'];
|
||||
|
||||
// Préparer le corps de l'email
|
||||
$body = $this->generateEmailBody($receiptData);
|
||||
|
||||
// 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";
|
||||
$fullBody .= "Content-Transfer-Encoding: base64\r\n";
|
||||
$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,
|
||||
$subject,
|
||||
$fullBody,
|
||||
$headers,
|
||||
'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
|
||||
*/
|
||||
public function markReceiptAsSent(int $passageId): void {
|
||||
$stmt = $this->db->prepare('
|
||||
UPDATE ope_pass
|
||||
SET date_sent_recu = NOW(), chk_email_sent = 1
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$passageId]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user