feat: Version 3.3.4 - Nouvelle architecture pages, optimisations widgets Flutter et API

- Mise à jour VERSION vers 3.3.4
- Optimisations et révisions architecture API (deploy-api.sh, scripts de migration)
- Ajout documentation Stripe Tap to Pay complète
- Migration vers polices Inter Variable pour Flutter
- Optimisations build Android et nettoyage fichiers temporaires
- Amélioration système de déploiement avec gestion backups
- Ajout scripts CRON et migrations base de données

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pierre
2025-10-05 20:11:15 +02:00
parent 2786252307
commit 570a1fa1f0
212 changed files with 24275 additions and 11321 deletions

View File

@@ -92,13 +92,13 @@ class AppConfig {
$this->config['app.geosector.fr'] = array_merge($baseConfig, [
'env' => 'production',
'database' => [
'host' => 'localhost',
'name' => 'geo_app',
'username' => 'geo_app_user_prod',
'password' => 'QO:96-SrHJ6k7-df*?k{4W6m',
'host' => '13.23.33.4', // Container maria4 sur IN4
'name' => 'pra_geo',
'username' => 'pra_geo_user',
'password' => 'd2jAAGGWi8fxFrWgXjOA',
],
'addresses_database' => [
'host' => '13.23.33.26',
'host' => '13.23.33.4', // Container maria4 sur IN4
'name' => 'adresses',
'username' => 'adr_geo_user',
'password' => 'd66,AdrGeo.User',
@@ -109,13 +109,20 @@ 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 actuelle - base locale dans rca-geo
'host' => 'localhost',
'name' => 'geo_app',
'username' => 'geo_app_user_rec',
'password' => 'QO:96df*?k-dS3KiO-{4W6m',
'password' => 'UPf3C0cQ805LypyM71iW', // À ajuster si nécessaire
],
'addresses_database' => [
'host' => '13.23.33.36',
'host' => '13.23.33.4', // Container maria3 sur IN3
'name' => 'adresses',
'username' => 'adr_geo_user',
'password' => 'd66,AdrGeoRec.User',
@@ -124,16 +131,23 @@ class AppConfig {
]);
// Configuration DÉVELOPPEMENT
$this->config['app.geo.dev'] = array_merge($baseConfig, [
$this->config['dapp.geosector.fr'] = array_merge($baseConfig, [
'env' => 'development',
'database' => [
'host' => '13.23.33.46',
// 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 actuelle - base locale dans dva-geo
'host' => 'localhost',
'name' => 'geo_app',
'username' => 'geo_app_user_dev',
'password' => '34GOz-X5gJu-oH@Fa3$#Z',
'password' => 'CBq9tKHj6PGPZuTmAHV7', // À ajuster si nécessaire
],
'addresses_database' => [
'host' => '13.23.33.46',
'host' => '13.23.33.4', // Container maria3 sur IN3
'name' => 'adresses',
'username' => 'adr_geo_user',
'password' => 'd66,AdrGeoDev.User',
@@ -148,7 +162,7 @@ class AppConfig {
if (empty($this->currentHost)) {
// Journaliser cette situation anormale
error_log("WARNING: No host detected, falling back to development environment");
$this->currentHost = 'app.geo.dev';
$this->currentHost = 'dapp.geosector.fr';
}
// Si l'hôte n'existe pas dans la configuration, tenter une correction
@@ -166,7 +180,7 @@ class AppConfig {
// Si toujours pas de correspondance, utiliser l'environnement de développement par défaut
if (!isset($this->config[$this->currentHost])) {
error_log("WARNING: Unknown host '{$this->currentHost}', falling back to development environment");
$this->currentHost = 'app.geo.dev';
$this->currentHost = 'dapp.geosector.fr';
}
}
@@ -186,8 +200,8 @@ 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, app.geo.dev)
*
* @return string L'identifiant de l'application (app.geosector.fr, rapp.geosector.fr, dapp.geosector.fr)
*/
public function getAppIdentifier(): string {
return $this->currentHost;

View File

@@ -593,6 +593,11 @@ class EntiteController {
$updateFields[] = 'chk_user_delete_pass = ?';
$params[] = $data['chk_user_delete_pass'] ? 1 : 0;
}
if (isset($data['chk_lot_actif'])) {
$updateFields[] = 'chk_lot_actif = ?';
$params[] = $data['chk_lot_actif'] ? 1 : 0;
}
}
// Si aucun champ à mettre à jour, retourner une erreur

View File

@@ -50,10 +50,10 @@ class LoginController {
$username = trim($data['username']);
$encryptedUsername = ApiService::encryptSearchableData($username);
// Récupérer le type d'utilisateur
// admin accessible uniquement aux fk_role>1
// user accessible uniquement aux fk_role=1
$roleCondition = ($interface === 'user') ? 'AND fk_role=1' : 'AND fk_role>1';
// Récupérer le type d'utilisateur
// user accessible aux fk_role=1 ET fk_role=2 (membres + admins amicale)
// 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', [
@@ -343,18 +343,26 @@ class LoginController {
// 3. Récupérer les passages selon l'interface et le rôle
if ($interface === 'user' && !empty($sectors)) {
// Interface utilisateur : passages liés aux secteurs de l'utilisateur
// Interface utilisateur : passages de l'utilisateur + passages à finaliser sur ses secteurs
$userId = $user['id'];
$sectorIds = array_column($sectors, 'id');
$sectorIdsString = implode(',', $sectorIds);
if (!empty($sectorIdsString)) {
$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
FROM ope_pass
WHERE fk_operation = ? AND fk_sector IN ($sectorIdsString) AND chk_active = 1"
"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_operation = ?
AND chk_active = 1
AND (
(fk_user = ?) -- TOUS les passages de l'utilisateur
OR
(fk_sector IN ($sectorIdsString) AND fk_type = 2 AND fk_user != ?) -- Passages type 2 des autres sur ses secteurs
)
ORDER BY passed_at DESC"
);
$passagesStmt->execute([$activeOperationId]);
$passagesStmt->execute([$activeOperationId, $userId, $userId]);
}
} elseif ($interface === 'admin' && $user['fk_role'] == 2) {
// Interface admin avec rôle 2 : tous les passages de l'opération
@@ -888,6 +896,700 @@ class LoginController {
}
}
public function refreshSession(): void {
try {
// 1. Récupérer l'ID utilisateur depuis la session active
$userId = Session::getUserId();
if (!$userId) {
Response::json(['error' => 'Session invalide'], 401);
return;
}
// 2. Récupérer le mode depuis l'URL
$mode = $_GET['mode'] ?? 'user';
// 3. Validation du mode
if (!in_array($mode, ['user', 'admin'])) {
Response::json(['error' => 'Mode invalide. Valeurs acceptées: user, admin'], 400);
return;
}
// 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
u.id, u.encrypted_email, u.encrypted_user_name, u.encrypted_name, u.user_pass_hash,
u.first_name, u.fk_role, u.fk_entite, u.fk_titre, u.chk_active, u.sect_name,
u.date_naissance, u.date_embauche, u.encrypted_phone, u.encrypted_mobile,
e.id AS entite_id, e.encrypted_name AS entite_encrypted_name,
e.adresse1, e.code_postal, e.ville, e.gps_lat, e.gps_lng, e.chk_active AS entite_chk_active
FROM users u
LEFT JOIN entites e ON u.fk_entite = e.id
WHERE u.id = ? AND u.chk_active != 0 ' . $roleCondition
);
$stmt->execute([$userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
LogService::log('Rafraîchissement session échoué : utilisateur non trouvé ou accès interdit', [
'level' => 'warning',
'userId' => $userId,
'mode' => $mode
]);
Response::json(['error' => 'Utilisateur non trouvé ou accès interdit à cette interface'], 403);
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('Rafraîchissement session échoué : entité non active', [
'level' => 'warning',
'userId' => $userId,
'entite_id' => $user['fk_entite']
]);
Response::json([
'status' => 'error',
'message' => 'Votre amicale n\'est pas activée. Veuillez contacter votre administrateur.'
], 403);
return;
}
// Déchiffrement du nom
$decryptedName = ApiService::decryptData($user['encrypted_name']);
$username = ApiService::decryptSearchableData($user['encrypted_user_name']);
// Déchiffrement de l'email si disponible
$email = '';
if (!empty($user['encrypted_email'])) {
$email = ApiService::decryptSearchableData($user['encrypted_email']);
if (empty($email)) {
LogService::log('Déchiffrement email échoué', [
'level' => 'error',
'message' => 'Déchiffrement de l\'email échoué',
'encrypted_email' => $user['encrypted_email'],
'user_id' => $user['id']
]);
Response::json([
'status' => 'error',
'message' => 'Erreur de déchiffrement de l\'email. Exécutez le script de migration pour résoudre ce problème.',
'debug_info' => [
'encrypted_email' => $user['encrypted_email'],
'user_id' => $user['id']
]
], 500);
return;
}
}
// Préparation des données utilisateur pour la réponse
$userData = [
'id' => $user['id'],
'fk_entite' => $user['fk_entite'] ?? null,
'fk_role' => $user['fk_role'] ?? '0',
'fk_titre' => $user['fk_titre'] ?? null,
'first_name' => $user['first_name'] ?? '',
'sect_name' => $user['sect_name'] ?? '',
'date_naissance' => $user['date_naissance'] ?? null,
'date_embauche' => $user['date_embauche'] ?? null,
'username' => $username,
'name' => $decryptedName
];
// Déchiffrement du téléphone
if (!empty($user['encrypted_phone'])) {
$userData['phone'] = ApiService::decryptData($user['encrypted_phone']);
} else {
$userData['phone'] = '';
}
// Déchiffrement du mobile
if (!empty($user['encrypted_mobile'])) {
$userData['mobile'] = ApiService::decryptData($user['encrypted_mobile']);
} else {
$userData['mobile'] = '';
}
$userData['email'] = $email;
// 5. Charger toutes les données selon le mode (MÊME LOGIQUE QUE LOGIN)
$operationsData = [];
$sectorsData = [];
$passagesData = [];
$usersSectorsData = [];
// Récupération des opérations selon les critères
$operationLimit = 0;
$activeOperationOnly = false;
if ($mode === 'user') {
$operationLimit = 1;
$activeOperationOnly = true;
} elseif ($mode === 'admin' && $user['fk_role'] == 2) {
$operationLimit = 3;
} elseif ($mode === 'admin' && $user['fk_role'] > 2) {
$operationLimit = 10;
} else {
$operationLimit = 0;
}
if ($operationLimit > 0 && !empty($user['fk_entite'])) {
$operationQuery = "SELECT id, fk_entite, libelle, date_deb, date_fin, chk_active
FROM operations
WHERE fk_entite = ?";
if ($activeOperationOnly) {
$operationQuery .= " AND chk_active = 1";
}
$operationQuery .= " ORDER BY id DESC LIMIT " . $operationLimit;
$operationStmt = $this->db->prepare($operationQuery);
$operationStmt->execute([$user['fk_entite']]);
$operations = $operationStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($operations)) {
foreach ($operations as $operation) {
$operationsData[] = [
'id' => $operation['id'],
'fk_entite' => $operation['fk_entite'],
'libelle' => $operation['libelle'],
'date_deb' => $operation['date_deb'],
'date_fin' => $operation['date_fin'],
'chk_active' => $operation['chk_active']
];
}
$activeOperationId = $operations[0]['id'];
// 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']]);
} elseif ($mode === 'admin' && $user['fk_role'] == 2) {
$sectorsStmt = $this->db->prepare(
'SELECT DISTINCT s.id, s.libelle, s.color, s.sector
FROM ope_sectors s
WHERE s.fk_operation = ? AND s.chk_active = 1'
);
$sectorsStmt->execute([$activeOperationId]);
} else {
$sectors = [];
$sectorsData = [];
}
if (isset($sectorsStmt)) {
$sectors = $sectorsStmt->fetchAll(PDO::FETCH_ASSOC);
} else {
$sectors = [];
}
if (!empty($sectors)) {
$sectorsData = $sectors;
// Récupérer les passages selon le mode et le rôle
if ($mode === 'user' && !empty($sectors)) {
$sectorIds = array_column($sectors, 'id');
$sectorIdsString = implode(',', $sectorIds);
if (!empty($sectorIdsString)) {
$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
FROM ope_pass
WHERE fk_operation = ?
AND chk_active = 1
AND (
(fk_user = ?)
OR
(fk_sector IN ($sectorIdsString) AND fk_type = 2 AND fk_user != ?)
)
ORDER BY passed_at DESC"
);
$passagesStmt->execute([$activeOperationId, $user['id'], $user['id']]);
}
} elseif ($mode === 'admin' && $user['fk_role'] == 2) {
$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
FROM ope_pass
WHERE fk_operation = ? AND chk_active = 1"
);
$passagesStmt->execute([$activeOperationId]);
} else {
$passages = [];
$passagesData = [];
}
if (isset($passagesStmt)) {
$passages = $passagesStmt->fetchAll(PDO::FETCH_ASSOC);
} else {
$passages = [];
}
if (!empty($passages)) {
foreach ($passages as &$passage) {
$passage['name'] = '';
if (!empty($passage['encrypted_name'])) {
$passage['name'] = ApiService::decryptData($passage['encrypted_name']);
}
unset($passage['encrypted_name']);
$passage['email'] = '';
if (!empty($passage['encrypted_email'])) {
$decryptedEmail = ApiService::decryptSearchableData($passage['encrypted_email']);
if ($decryptedEmail) {
$passage['email'] = $decryptedEmail;
}
}
unset($passage['encrypted_email']);
$passage['phone'] = '';
if (!empty($passage['encrypted_phone'])) {
$passage['phone'] = ApiService::decryptData($passage['encrypted_phone']);
}
unset($passage['encrypted_phone']);
}
$passagesData = $passages;
}
// Récupérer les utilisateurs des secteurs partagés
if (($mode === 'user' || ($mode === 'admin' && $user['fk_role'] == 2)) && !empty($sectors)) {
$sectorIds = array_column($sectors, 'id');
$sectorIdsString = implode(',', $sectorIds);
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
AND u.id != ?"
);
$usersSectorsStmt->execute([$activeOperationId, $user['id']]);
$usersSectors = $usersSectorsStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($usersSectors)) {
foreach ($usersSectors as &$userSector) {
if (!empty($userSector['encrypted_name'])) {
$userSector['name'] = ApiService::decryptData($userSector['encrypted_name']);
unset($userSector['encrypted_name']);
}
}
$usersSectorsData = $usersSectors;
}
}
} else {
$usersSectorsData = [];
}
}
}
}
// 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']]);
$membres = $membresStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($membres)) {
foreach ($membres as $membre) {
$membreItem = [
'id' => $membre['id'],
'fk_role' => $membre['fk_role'],
'fk_entite' => $membre['fk_entite'],
'fk_titre' => $membre['fk_titre'],
'first_name' => $membre['first_name'] ?? '',
'sect_name' => $membre['sect_name'] ?? '',
'date_naissance' => $membre['date_naissance'] ?? null,
'date_embauche' => $membre['date_embauche'] ?? null,
'chk_active' => $membre['chk_active']
];
if (!empty($membre['encrypted_name'])) {
$membreItem['name'] = ApiService::decryptData($membre['encrypted_name']);
} else {
$membreItem['name'] = '';
}
if (!empty($membre['encrypted_user_name'])) {
$membreItem['username'] = ApiService::decryptSearchableData($membre['encrypted_user_name']);
} else {
$membreItem['username'] = '';
}
if (!empty($membre['encrypted_phone'])) {
$membreItem['phone'] = ApiService::decryptData($membre['encrypted_phone']);
} else {
$membreItem['phone'] = '';
}
if (!empty($membre['encrypted_mobile'])) {
$membreItem['mobile'] = ApiService::decryptData($membre['encrypted_mobile']);
} else {
$membreItem['mobile'] = '';
}
if (!empty($membre['encrypted_email'])) {
$decryptedEmail = ApiService::decryptSearchableData($membre['encrypted_email']);
if ($decryptedEmail) {
$membreItem['email'] = $decryptedEmail;
}
} else {
$membreItem['email'] = '';
}
$membresData[] = $membreItem;
}
}
}
// Récupérer les amicales selon le rôle
$amicalesData = [];
if (!empty($user['fk_entite'])) {
if ($user['fk_role'] <= 2) {
$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
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
WHERE e.id = ? AND e.chk_active = 1'
);
$amicaleStmt->execute([$user['fk_entite']]);
$amicales = $amicaleStmt->fetchAll(PDO::FETCH_ASSOC);
} else {
$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
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
WHERE e.id != 1 AND e.chk_active = 1'
);
$amicaleStmt->execute();
$amicales = $amicaleStmt->fetchAll(PDO::FETCH_ASSOC);
}
if (!empty($amicales)) {
foreach ($amicales as &$amicale) {
if (!empty($amicale['name'])) {
$amicale['name'] = ApiService::decryptData($amicale['name']);
}
if (!empty($amicale['email'])) {
$decryptedEmail = ApiService::decryptSearchableData($amicale['email']);
if ($decryptedEmail) {
$amicale['email'] = $decryptedEmail;
}
}
if (!empty($amicale['phone'])) {
$amicale['phone'] = ApiService::decryptData($amicale['phone']);
}
if (!empty($amicale['mobile'])) {
$amicale['mobile'] = ApiService::decryptData($amicale['mobile']);
}
if (!empty($amicale['stripe_id'])) {
$amicale['stripe_id'] = ApiService::decryptData($amicale['stripe_id']);
}
}
$amicalesData = $amicales;
}
}
// Récupérer les entités de type 1 pour les utilisateurs avec fk_role > 2
$entitesData = [];
if ($user['fk_role'] > 2) {
$entitesStmt = $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
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
WHERE e.fk_type = 1 AND e.chk_active = 1'
);
$entitesStmt->execute();
$entites = $entitesStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($entites)) {
foreach ($entites as &$entite) {
if (!empty($entite['name'])) {
$entite['name'] = ApiService::decryptData($entite['name']);
}
if (!empty($entite['email'])) {
$decryptedEmail = ApiService::decryptSearchableData($entite['email']);
if ($decryptedEmail) {
$entite['email'] = $decryptedEmail;
}
}
if (!empty($entite['phone'])) {
$entite['phone'] = ApiService::decryptData($entite['phone']);
}
if (!empty($entite['mobile'])) {
$entite['mobile'] = ApiService::decryptData($entite['mobile']);
}
if (!empty($entite['stripe_id'])) {
$entite['stripe_id'] = ApiService::decryptData($entite['stripe_id']);
}
}
$entitesData = $entites;
}
}
// Préparation de la réponse (MÊME STRUCTURE QUE LOGIN)
$response = [
'status' => 'success',
'message' => 'Session rafraîchie',
'session_id' => session_id(),
'session_expiry' => date('c', strtotime('+24 hours')),
'user' => $userData
];
// Ajout des amicales avec logo
if (!empty($amicalesData)) {
$logoData = null;
if (!empty($user['fk_entite'])) {
$logoStmt = $this->db->prepare('
SELECT id, fichier, file_path, file_type, mime_type, processed_width, processed_height
FROM medias
WHERE support = ? AND support_id = ? AND file_category = ?
ORDER BY created_at DESC
LIMIT 1
');
$logoStmt->execute(['entite', $user['fk_entite'], 'logo']);
$logo = $logoStmt->fetch(PDO::FETCH_ASSOC);
if ($logo && file_exists($logo['file_path'])) {
$imageData = file_get_contents($logo['file_path']);
if ($imageData !== false) {
$base64 = base64_encode($imageData);
$dataUrl = 'data:' . $logo['mime_type'] . ';base64,' . $base64;
$logoData = [
'id' => $logo['id'],
'data_url' => $dataUrl,
'file_name' => $logo['fichier'],
'mime_type' => $logo['mime_type'],
'width' => $logo['processed_width'],
'height' => $logo['processed_height']
];
}
}
}
if (count($amicalesData) === 1) {
$response['amicale'] = $amicalesData[0];
if ($logoData !== null) {
$response['amicale']['logo'] = $logoData;
}
} else {
$response['amicale'] = $amicalesData;
if ($logoData !== null && !empty($user['fk_entite'])) {
foreach ($response['amicale'] as &$amicale) {
if ($amicale['id'] == $user['fk_entite']) {
$amicale['logo'] = $logoData;
break;
}
}
}
}
}
$response['clients'] = $entitesData;
if (!empty($membresData)) {
$response['membres'] = $membresData;
}
if (!empty($operationsData)) {
$response['operations'] = $operationsData;
}
if (!empty($sectorsData)) {
$response['sectors'] = $sectorsData;
}
if (!empty($passagesData)) {
$response['passages'] = $passagesData;
}
if (!empty($usersSectorsData)) {
$response['users_sectors'] = $usersSectorsData;
}
// Récupérer les régions selon le rôle
$regionsData = [];
if ($user['fk_role'] <= 2 && !empty($user['fk_entite'])) {
$amicaleStmt = $this->db->prepare('SELECT code_postal FROM entites WHERE id = ?');
$amicaleStmt->execute([$user['fk_entite']]);
$amicale = $amicaleStmt->fetch(PDO::FETCH_ASSOC);
if (!empty($amicale) && !empty($amicale['code_postal'])) {
$departement = substr($amicale['code_postal'], 0, 2);
$regionStmt = $this->db->prepare(
'SELECT id, fk_pays, libelle, libelle_long, table_osm, departements, chk_active
FROM x_regions
WHERE FIND_IN_SET(?, departements) > 0 AND chk_active = 1'
);
$regionStmt->execute([$departement]);
$regions = $regionStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($regions)) {
$regionsData = $regions;
}
}
} else {
$regionStmt = $this->db->prepare(
'SELECT id, fk_pays, libelle, libelle_long, table_osm, departements, chk_active
FROM x_regions
WHERE chk_active = 1'
);
$regionStmt->execute();
$regions = $regionStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($regions)) {
$regionsData = $regions;
}
}
if (!empty($regionsData)) {
$response['regions'] = $regionsData;
}
// Ajout des informations du module chat
$chatData = [];
$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);
$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);
$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']
];
}
$chatData['chat_enabled'] = true;
$response['chat'] = $chatData;
// 6. Envoi de la réponse
Response::json($response);
} catch (PDOException $e) {
LogService::log('Erreur base de données lors du rafraîchissement de session', [
'level' => 'error',
'error' => $e->getMessage(),
'code' => $e->getCode()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
} catch (Exception $e) {
LogService::log('Erreur inattendue lors du rafraîchissement de session', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Une erreur inattendue est survenue'
], 500);
}
}
public function lostPassword(): void {
try {
$data = Request::getJson();

File diff suppressed because it is too large Load Diff

View File

@@ -119,9 +119,17 @@ class PassageController {
$errors[] = 'La ville est obligatoire';
}
// Validation du nom (chiffré)
if (!isset($data['encrypted_name']) && !isset($data['name'])) {
$errors[] = 'Le nom est obligatoire';
// Validation du nom (chiffré) - obligatoire seulement si (type=1 Effectué ou 5 Lot) ET email présent
$fk_type = isset($data['fk_type']) ? (int)$data['fk_type'] : 0;
$hasEmail = (isset($data['email']) && !empty(trim($data['email']))) ||
(isset($data['encrypted_email']) && !empty($data['encrypted_email']));
if (($fk_type === 1 || $fk_type === 5) && $hasEmail) {
if (!isset($data['encrypted_name']) && !isset($data['name'])) {
$errors[] = 'Le nom est obligatoire pour ce type de passage avec email';
} elseif (isset($data['name']) && empty(trim($data['name']))) {
$errors[] = 'Le nom ne peut pas être vide pour ce type de passage avec email';
}
}
// Validation du montant
@@ -157,6 +165,15 @@ class PassageController {
}
}
// Validation de l'ID Stripe si fourni
if (isset($data['stripe_payment_id']) && !empty($data['stripe_payment_id'])) {
$stripeId = trim($data['stripe_payment_id']);
// L'ID PaymentIntent Stripe doit commencer par 'pi_'
if (!preg_match('/^pi_[a-zA-Z0-9]{24,}$/', $stripeId)) {
$errors[] = 'Format d\'ID de paiement Stripe invalide';
}
}
return empty($errors) ? null : $errors;
}
@@ -210,13 +227,13 @@ class PassageController {
// Requête principale avec jointures
$stmt = $this->db->prepare("
SELECT
SELECT
p.id, p.fk_operation, p.fk_sector, p.fk_user, p.fk_adresse,
p.passed_at, p.fk_type, p.numero, p.rue, p.rue_bis, p.ville,
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.docremis, p.date_repasser, p.nb_passages,
p.chk_email_sent, p.stripe_payment_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
@@ -389,11 +406,11 @@ class PassageController {
$offset = ($page - 1) * $limit;
$stmt = $this->db->prepare('
SELECT
SELECT
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.chk_email_sent,
p.encrypted_email, p.encrypted_phone, p.stripe_payment_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
@@ -494,7 +511,13 @@ class PassageController {
}
// Chiffrement des données sensibles
$encryptedName = isset($data['name']) ? ApiService::encryptData($data['name']) : (isset($data['encrypted_name']) ? $data['encrypted_name'] : '');
$encryptedName = '';
if (isset($data['name']) && !empty(trim($data['name']))) {
$encryptedName = ApiService::encryptData($data['name']);
} elseif (isset($data['encrypted_name']) && !empty($data['encrypted_name'])) {
$encryptedName = $data['encrypted_name'];
}
// Le nom peut rester vide si les conditions ne l'exigent pas
$encryptedEmail = isset($data['email']) && !empty($data['email']) ?
ApiService::encryptSearchableData($data['email']) : '';
$encryptedPhone = isset($data['phone']) && !empty($data['phone']) ?
@@ -524,6 +547,7 @@ class PassageController {
'remarque' => $data['remarque'] ?? '',
'encrypted_email' => $encryptedEmail,
'encrypted_phone' => $encryptedPhone,
'stripe_payment_id' => isset($data['stripe_payment_id']) ? trim($data['stripe_payment_id']) : null,
'nom_recu' => $data['nom_recu'] ?? null,
'date_recu' => isset($data['date_recu']) ? $data['date_recu'] : null,
'docremis' => isset($data['docremis']) ? (int)$data['docremis'] : 0,
@@ -646,7 +670,7 @@ class PassageController {
}
$stmt = $this->db->prepare('
SELECT p.id, p.fk_operation
SELECT p.id, p.fk_operation, p.fk_type, p.fk_user
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
@@ -673,6 +697,19 @@ class PassageController {
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;
LogService::log('Attribution automatique d\'un passage type 2 à l\'utilisateur', [
'level' => 'info',
'passageId' => $passageId,
'ancien_user' => $passage['fk_user'],
'nouveau_user' => $userId
]);
}
// Construction de la requête de mise à jour dynamique
$updateFields = [];
$params = [];
@@ -697,6 +734,7 @@ class PassageController {
'montant',
'fk_type_reglement',
'remarque',
'stripe_payment_id',
'nom_recu',
'date_recu',
'docremis',
@@ -714,9 +752,10 @@ class PassageController {
}
// Gestion des champs chiffrés
if (isset($data['name'])) {
if (array_key_exists('name', $data)) {
$updateFields[] = "encrypted_name = ?";
$params[] = ApiService::encryptData($data['name']);
// Permettre de vider le nom si les conditions le permettent
$params[] = !empty(trim($data['name'])) ? ApiService::encryptData($data['name']) : '';
}
if (isset($data['email'])) {

View File

@@ -544,24 +544,85 @@ class SectorController
$stmt->execute($params);
}
// Gestion des membres
if (isset($data['membres'])) {
// Gestion des membres (reçus comme 'users' depuis Flutter)
if (isset($data['users'])) {
$this->logService->info('[UPDATE USERS] Début modification des membres', [
'sector_id' => $id,
'users_demandes' => $data['users'],
'nb_users' => count($data['users'])
]);
// Récupérer l'opération du secteur pour l'INSERT
$opQuery = "SELECT fk_operation FROM ope_sectors WHERE id = :sector_id";
$this->logService->info('[UPDATE USERS] SQL - Récupération fk_operation', [
'query' => $opQuery,
'params' => ['sector_id' => $id]
]);
$opStmt = $this->db->prepare($opQuery);
$opStmt->execute(['sector_id' => $id]);
$operationId = $opStmt->fetch()['fk_operation'];
$this->logService->info('[UPDATE USERS] fk_operation récupéré', [
'operation_id' => $operationId
]);
// Supprimer les affectations existantes
$deleteQuery = "DELETE FROM ope_users_sectors WHERE fk_sector = :sector_id";
$this->logService->info('[UPDATE USERS] SQL - Suppression des anciens membres', [
'query' => $deleteQuery,
'params' => ['sector_id' => $id]
]);
$deleteStmt = $this->db->prepare($deleteQuery);
$deleteStmt->execute(['sector_id' => $id]);
$deletedCount = $deleteStmt->rowCount();
$this->logService->info('[UPDATE USERS] Membres supprimés', [
'nb_deleted' => $deletedCount
]);
// Ajouter les nouvelles affectations
if (!empty($data['membres'])) {
$insertQuery = "INSERT INTO ope_users_sectors (fk_user, fk_sector) VALUES (:user_id, :sector_id)";
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)";
$this->logService->info('[UPDATE USERS] SQL - Requête INSERT préparée', [
'query' => $insertQuery
]);
$insertStmt = $this->db->prepare($insertQuery);
foreach ($data['membres'] as $memberId) {
$insertStmt->execute([
'user_id' => $memberId,
'sector_id' => $id
]);
$insertedUsers = [];
$failedUsers = [];
foreach ($data['users'] as $memberId) {
try {
$params = [
'operation_id' => $operationId,
'user_id' => $memberId,
'sector_id' => $id,
'user_creat' => $_SESSION['user_id'] ?? null
];
$this->logService->info('[UPDATE USERS] SQL - INSERT user', [
'params' => $params
]);
$insertStmt->execute($params);
$insertedUsers[] = $memberId;
$this->logService->info('[UPDATE USERS] User inséré avec succès', [
'user_id' => $memberId
]);
} catch (\PDOException $e) {
$failedUsers[] = $memberId;
$this->logService->warning('[UPDATE USERS] ERREUR insertion user', [
'sector_id' => $id,
'user_id' => $memberId,
'error' => $e->getMessage(),
'error_code' => $e->getCode()
]);
}
}
$this->logService->info('[UPDATE USERS] Résultat des insertions', [
'users_demandes' => $data['users'],
'users_inseres' => $insertedUsers,
'users_echoues' => $failedUsers,
'nb_succes' => count($insertedUsers),
'nb_echecs' => count($failedUsers)
]);
}
}
@@ -651,7 +712,8 @@ class SectorController
$this->logService->info('[UPDATE] Début mise à jour des passages', ['sector_id' => $id]);
$passageCounters = $this->updatePassagesForSector($id, $data['sector']);
}
// Commit des modifications (users et/ou secteur)
$this->db->commit();
// Récupérer le secteur mis à jour
@@ -711,14 +773,29 @@ class SectorController
$passagesDecrypted[] = $passage;
}
// Récupérer les users affectés
// 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
FROM ope_users_sectors ous
JOIN users u ON ous.fk_user = u.id
WHERE ous.fk_sector = :sector_id";
WHERE ous.fk_sector = :sector_id
ORDER BY u.id";
$this->logService->info('[UPDATE USERS] SQL - Récupération finale des users', [
'query' => $usersQuery,
'params' => ['sector_id' => $id]
]);
$usersStmt = $this->db->prepare($usersQuery);
$usersStmt->execute(['sector_id' => $id]);
$usersSectors = $usersStmt->fetchAll(\PDO::FETCH_ASSOC);
$userIds = array_column($usersSectors, 'id');
$this->logService->info('[UPDATE USERS] Users récupérés après commit', [
'sector_id' => $id,
'users_ids' => $userIds,
'nb_users' => count($userIds),
'users_demandes_initialement' => $data['users'] ?? []
]);
// Déchiffrer les noms des utilisateurs
$usersDecrypted = [];
@@ -1066,6 +1143,7 @@ class SectorController
/**
* Mettre à jour les passages affectés à un secteur lors de la modification du périmètre
* VERSION OPTIMISÉE avec requêtes groupées
* Retourne un tableau avec les compteurs détaillés
*/
private function updatePassagesForSector($sectorId, $newSectorCoords): array
@@ -1080,18 +1158,18 @@ class SectorController
try {
// Récupérer l'opération et l'entité du secteur
$sectorQuery = "SELECT o.id as operation_id, o.fk_entite, s.fk_operation
$sectorQuery = "SELECT o.id as operation_id, o.fk_entite, s.fk_operation
FROM ope_sectors s
JOIN operations o ON s.fk_operation = o.id
WHERE s.id = :sector_id";
$sectorStmt = $this->db->prepare($sectorQuery);
$sectorStmt->execute(['sector_id' => $sectorId]);
$sectorInfo = $sectorStmt->fetch();
if (!$sectorInfo) {
return 0;
return $counters;
}
$operationId = $sectorInfo['operation_id'];
$entityId = $sectorInfo['fk_entite'];
@@ -1099,7 +1177,7 @@ class SectorController
$points = explode('#', rtrim($newSectorCoords, '#'));
$coordinates = [];
$polygonPoints = [];
foreach ($points as $point) {
if (!empty($point)) {
list($lat, $lng) = explode('/', $point);
@@ -1110,170 +1188,249 @@ class SectorController
$polygonPoints[] = $polygonPoints[0]; // Fermer le polygone
$polygonString = 'POLYGON((' . implode(',', $polygonPoints) . '))';
// 1. VÉRIFICATION GÉOGRAPHIQUE DES PASSAGES EXISTANTS
$checkPassagesQuery = "SELECT id, gps_lat, gps_lng, fk_type, encrypted_name
FROM ope_pass
WHERE fk_sector = :sector_id
AND gps_lat IS NOT NULL
AND gps_lng IS NOT NULL";
// 1. VÉRIFICATION GÉOGRAPHIQUE DES PASSAGES EXISTANTS (OPTIMISÉE)
// Utiliser une seule requête pour vérifier tous les passages
$checkPassagesQuery = "
SELECT
p.id,
p.gps_lat,
p.gps_lng,
p.fk_type,
p.encrypted_name,
ST_Contains(ST_GeomFromText(:polygon, 4326),
POINT(CAST(p.gps_lng AS DECIMAL(10,8)),
CAST(p.gps_lat AS DECIMAL(10,8)))) as is_inside
FROM ope_pass p
WHERE p.fk_sector = :sector_id
AND p.gps_lat IS NOT NULL
AND p.gps_lng IS NOT NULL";
$checkStmt = $this->db->prepare($checkPassagesQuery);
$checkStmt->execute(['sector_id' => $sectorId]);
$checkStmt->execute([
'sector_id' => $sectorId,
'polygon' => $polygonString
]);
$existingPassages = $checkStmt->fetchAll();
$passagesToDelete = [];
$passagesToOrphan = [];
foreach ($existingPassages as $passage) {
// Vérifier si le passage est dans le nouveau polygone
$pointInPolygonQuery = "SELECT ST_Contains(ST_GeomFromText(:polygon, 4326),
POINT(CAST(:lng AS DECIMAL(10,8)),
CAST(:lat AS DECIMAL(10,8)))) as is_inside";
$pointStmt = $this->db->prepare($pointInPolygonQuery);
$pointStmt->execute([
'polygon' => $polygonString,
'lng' => $passage['gps_lng'],
'lat' => $passage['gps_lat']
]);
$result = $pointStmt->fetch();
if ($result['is_inside'] == 0) {
if ($passage['is_inside'] == 0) {
// Le passage est hors du nouveau périmètre
// Vérifier si c'est un passage non visité (fk_type=2 ET encrypted_name vide)
if ($passage['fk_type'] == 2 && ($passage['encrypted_name'] === '' || $passage['encrypted_name'] === null)) {
// Passage non visité : à supprimer
$passagesToDelete[] = $passage['id'];
$counters['passages_deleted'] = ($counters['passages_deleted'] ?? 0) + 1;
$counters['passages_deleted']++;
} else {
// Passage visité : mettre en orphelin
$orphanQuery = "UPDATE ope_pass SET fk_sector = NULL WHERE id = :passage_id";
$orphanStmt = $this->db->prepare($orphanQuery);
$orphanStmt->execute(['passage_id' => $passage['id']]);
// Passage visité : à mettre en orphelin
$passagesToOrphan[] = $passage['id'];
$counters['passages_orphaned']++;
}
} else {
$counters['passages_kept']++;
}
}
// Supprimer les passages non visités qui sont hors zone
// Supprimer les passages non visités en une seule requête
if (!empty($passagesToDelete)) {
$deleteQuery = "DELETE FROM ope_pass WHERE id IN (" . implode(',', $passagesToDelete) . ")";
$this->db->exec($deleteQuery);
$placeholders = str_repeat('?,', count($passagesToDelete) - 1) . '?';
$deleteQuery = "DELETE FROM ope_pass WHERE id IN ($placeholders)";
$deleteStmt = $this->db->prepare($deleteQuery);
$deleteStmt->execute($passagesToDelete);
}
// Mettre en orphelin les passages visités en une seule requête
if (!empty($passagesToOrphan)) {
$placeholders = str_repeat('?,', count($passagesToOrphan) - 1) . '?';
$orphanQuery = "UPDATE ope_pass SET fk_sector = NULL WHERE id IN ($placeholders)";
$orphanStmt = $this->db->prepare($orphanQuery);
$orphanStmt->execute($passagesToOrphan);
}
// 2. CRÉATION/MISE À JOUR DES PASSAGES POUR LES NOUVELLES ADRESSES
// 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";
$addressesStmt = $this->db->prepare($addressesQuery);
$addressesStmt->execute(['sector_id' => $sectorId]);
$addresses = $addressesStmt->fetchAll();
$this->logService->info('[updatePassagesForSector] Adresses dans sectors_adresses', [
'sector_id' => $sectorId,
'nb_addresses' => count($addresses)
]);
// Récupérer le premier utilisateur affecté au secteur
$userQuery = "SELECT fk_user FROM ope_users_sectors WHERE fk_sector = :sector_id LIMIT 1";
$userStmt = $this->db->prepare($userQuery);
$userStmt->execute(['sector_id' => $sectorId]);
$firstUser = $userStmt->fetch();
$firstUserId = $firstUser ? $firstUser['fk_user'] : null;
if ($firstUserId && !empty($addresses)) {
$this->logService->info('[updatePassagesForSector] Création passages pour user', [
$this->logService->info('[updatePassagesForSector] Optimisation passages', [
'user_id' => $firstUserId,
'nb_addresses_to_process' => count($addresses)
'nb_addresses' => count($addresses)
]);
// Préparer la requête de création de passage (même format que dans create)
$createPassageQuery = "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
)";
$createStmt = $this->db->prepare($createPassageQuery);
// 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
$existingQuery = "
SELECT id, fk_adresse, numero, rue, rue_bis, ville
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) . ")";
$existingStmt = $this->db->prepare($existingQuery);
$existingStmt->execute($params);
$existingPassages = $existingStmt->fetchAll();
// Indexer les passages existants pour recherche rapide
$passagesByAddress = [];
$passagesByData = [];
foreach ($existingPassages as $p) {
if (!empty($p['fk_adresse'])) {
$passagesByAddress[$p['fk_adresse']] = $p;
}
$dataKey = $p['numero'] . '|' . $p['rue'] . '|' . $p['rue_bis'] . '|' . $p['ville'];
$passagesByData[$dataKey] = $p;
}
// Préparer les listes pour batch insert/update
$toInsert = [];
$toUpdate = [];
foreach ($addresses as $address) {
// 2.1 Vérification primaire par fk_adresse
if (!empty($address['fk_adresse'])) {
$checkByAddressQuery = "SELECT id FROM ope_pass
WHERE fk_operation = :operation_id
AND fk_adresse = :fk_adresse";
$checkByAddressStmt = $this->db->prepare($checkByAddressQuery);
$checkByAddressStmt->execute([
'operation_id' => $operationId,
'fk_adresse' => $address['fk_adresse']
]);
if ($checkByAddressStmt->fetch()) {
continue; // Passage déjà existant, passer au suivant
}
// 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
}
// 2.2 Vérification secondaire par données d'adresse
$checkByDataQuery = "SELECT id FROM ope_pass
WHERE fk_operation = :operation_id
AND numero = :numero
AND rue_bis = :rue_bis
AND rue = :rue
AND ville = :ville";
$checkByDataStmt = $this->db->prepare($checkByDataQuery);
$checkByDataStmt->execute([
'operation_id' => $operationId,
'numero' => $address['numero'],
'rue_bis' => $address['rue_bis'],
'rue' => $address['rue'],
'ville' => $address['ville']
]);
$matchingPassages = $checkByDataStmt->fetchAll();
if (!empty($matchingPassages)) {
// Mettre à jour les passages trouvés avec le fk_adresse
if (!empty($address['fk_adresse'])) {
$updateQuery = "UPDATE ope_pass SET fk_adresse = :fk_adresse WHERE id = :passage_id";
$updateStmt = $this->db->prepare($updateQuery);
foreach ($matchingPassages as $matchingPassage) {
$updateStmt->execute([
'fk_adresse' => $address['fk_adresse'],
'passage_id' => $matchingPassage['id']
]);
$counters['passages_updated']++;
}
$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']
];
}
continue;
} else {
// Nouveau passage à créer
$toInsert[] = $address;
}
// 2.3 Création du passage (aucun passage existant trouvé)
}
// INSERT MULTIPLE en une seule requête
if (!empty($toInsert)) {
$values = [];
$insertParams = [];
$paramIndex = 0;
foreach ($toInsert as $addr) {
$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)";
$insertParams["op$paramIndex"] = $operationId;
$insertParams["sect$paramIndex"] = $sectorId;
$insertParams["usr$paramIndex"] = $firstUserId;
$insertParams["addr$paramIndex"] = $addr['fk_adresse'];
$insertParams["num$paramIndex"] = $addr['numero'];
$insertParams["rue$paramIndex"] = $addr['rue'];
$insertParams["bis$paramIndex"] = $addr['rue_bis'];
$insertParams["ville$paramIndex"] = $addr['ville'];
$insertParams["lat$paramIndex"] = $addr['gps_lat'];
$insertParams["lng$paramIndex"] = $addr['gps_lng'];
$insertParams["creat$paramIndex"] = $_SESSION['user_id'] ?? null;
$paramIndex++;
}
$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)
VALUES " . implode(',', $values);
try {
$createStmt->execute([
'operation_id' => $operationId,
'sector_id' => $sectorId,
'user_id' => $firstUserId,
'fk_adresse' => $address['fk_adresse'],
'numero' => $address['numero'],
'rue' => $address['rue'],
'rue_bis' => $address['rue_bis'],
'ville' => $address['ville'],
'gps_lat' => $address['gps_lat'],
'gps_lng' => $address['gps_lng'],
'user_creat' => $_SESSION['user_id'] ?? null
]);
$counters['passages_created']++;
$insertStmt = $this->db->prepare($insertQuery);
$insertStmt->execute($insertParams);
$counters['passages_created'] = count($toInsert);
} catch (\Exception $e) {
$this->logService->warning('Erreur lors de la création d\'un passage pendant update', [
$this->logService->error('Erreur lors de l\'insertion multiple des passages', [
'sector_id' => $sectorId,
'address' => $address,
'error' => $e->getMessage()
]);
}
}
// UPDATE MULTIPLE avec CASE WHEN
if (!empty($toUpdate)) {
$updateIds = array_column($toUpdate, 'id');
$placeholders = str_repeat('?,', count($updateIds) - 1) . '?';
$caseWhen = [];
$updateParams = [];
foreach ($toUpdate as $upd) {
$caseWhen[] = "WHEN id = ? THEN ?";
$updateParams[] = $upd['id'];
$updateParams[] = $upd['fk_adresse'];
}
$updateQuery = "UPDATE ope_pass
SET fk_adresse = CASE " . implode(' ', $caseWhen) . " END
WHERE id IN ($placeholders)";
try {
$updateStmt = $this->db->prepare($updateQuery);
$updateStmt->execute(array_merge($updateParams, $updateIds));
$counters['passages_updated'] = count($toUpdate);
} catch (\Exception $e) {
$this->logService->error('Erreur lors de la mise à jour 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

@@ -137,111 +137,143 @@ class StripeController extends Controller {
}
}
/**
* POST /api/stripe/locations
* Créer une Location pour Terminal/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', 403);
return;
}
$data = $this->getJsonInput();
$entiteId = $data['fk_entite'] ?? Session::getEntityId();
$result = $this->stripeService->createLocation($entiteId);
if ($result['success']) {
$this->sendSuccess($result);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/terminal/connection-token
* Créer un token de connexion pour Terminal/Tap to Pay
*/
public function createConnectionToken(): void {
try {
$this->requireAuth();
$entiteId = Session::getEntityId();
if (!$entiteId) {
$this->sendError('Entité non définie', 400);
return;
}
$result = $this->stripeService->createConnectionToken($entiteId);
if ($result['success']) {
$this->sendSuccess(['secret' => $result['secret']]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/payments/create-intent
* Créer une intention de paiement
* Créer une intention de paiement pour Tap to Pay ou paiement Web
*
* Payload Tap to Pay:
* {
* "amount": 2500,
* "currency": "eur",
* "description": "Calendrier pompiers - Passage #789",
* "payment_method_types": ["card_present"],
* "capture_method": "automatic",
* "passage_id": 789,
* "amicale_id": 42,
* "member_id": 156,
* "stripe_account": "acct_1O3ABC456DEF789",
* "location_id": "tml_FGH123456789",
* "metadata": {...}
* }
*/
public function createPaymentIntent(): void {
try {
$this->requireAuth();
$data = $this->getJsonInput();
// Validation
$amount = $data['amount'] ?? 0;
// Validation des champs requis
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 passage_id (doit être > 0 car le passage est créé avant)
if ($passageId <= 0) {
$this->sendError('passage_id invalide. Le passage doit être créé avant le paiement', 400);
return;
}
// Validation du montant
if ($amount < 100) {
$this->sendError('Le montant minimum est de 1€ (100 centimes)', 400);
return;
}
if ($amount > 50000) {
$this->sendError('Le montant maximum est de 500€', 400);
if ($amount > 99900) { // 999€ max selon la doc
$this->sendError('Le montant maximum est de 999€', 400);
return;
}
// Vérifier que le passage existe et appartient à l'utilisateur
$stmt = $this->db->prepare('
SELECT p.*, o.fk_entite
FROM ope_pass p
JOIN operations o ON p.fk_operation = o.id
WHERE p.id = ? AND p.fk_user = ?
');
$stmt->execute([$passageId, Session::getUserId()]);
$passage = $stmt->fetch();
if (!$passage) {
$this->sendError('Passage non trouvé ou non autorisé', 404);
return;
}
// Vérifier qu'il n'y a pas déjà un paiement Stripe pour ce passage
if (!empty($passage['stripe_payment_id'])) {
$this->sendError('Un paiement Stripe existe déjà pour ce passage', 400);
return;
}
// Vérifier que le montant correspond (passage.montant est en euros, amount en centimes)
$expectedAmount = (int)($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'];
// Déterminer le type de paiement (Tap to Pay ou Web)
$paymentMethodTypes = $data['payment_method_types'] ?? ['card_present'];
$isTapToPay = in_array('card_present', $paymentMethodTypes);
// Préparer les paramètres pour StripeService
$params = [
'amount' => $amount,
'fk_entite' => $data['fk_entite'] ?? Session::getEntityId(),
'fk_user' => Session::getUserId(),
'metadata' => $data['metadata'] ?? []
'currency' => $data['currency'] ?? 'eur',
'description' => $data['description'] ?? "Calendrier pompiers - Passage #$passageId",
'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(),
'stripe_account' => $data['stripe_account'] ?? null,
'metadata' => array_merge(
[
'passage_id' => (string)$passageId,
'amicale_id' => (string)($data['amicale_id'] ?? $entiteId),
'member_id' => (string)($data['member_id'] ?? Session::getUserId()),
'type' => $isTapToPay ? 'tap_to_pay' : 'web'
],
$data['metadata'] ?? []
)
];
// Ajouter location_id si fourni (pour Tap to Pay)
if (isset($data['location_id'])) {
$params['location_id'] = $data['location_id'];
}
// Créer le PaymentIntent via StripeService
$result = $this->stripeService->createPaymentIntent($params);
if ($result['success']) {
// Mettre à jour le passage avec le stripe_payment_id
$stmt = $this->db->prepare('
UPDATE ope_pass
SET stripe_payment_id = ?, updated_at = NOW()
WHERE id = ?
');
$stmt->execute([$result['payment_intent_id'], $passageId]);
// Retourner la réponse
$this->sendSuccess([
'client_secret' => $result['client_secret'],
'payment_intent_id' => $result['payment_intent_id'],
'amount' => $result['amount'],
'application_fee' => $result['application_fee']
'currency' => $params['currency'],
'passage_id' => $passageId,
'type' => $isTapToPay ? 'tap_to_pay' : 'web'
]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
@@ -249,60 +281,78 @@ class StripeController extends Controller {
/**
* GET /api/stripe/payments/{paymentIntentId}
* Récupérer le statut d'un paiement
* Récupérer le statut d'un paiement depuis ope_pass et Stripe
*/
public function getPaymentStatus(string $paymentIntentId): void {
try {
$this->requireAuth();
$stmt = $this->db->prepare(
"SELECT spi.*, e.nom as entite_nom, u.nom as user_nom, u.prenom as user_prenom
FROM stripe_payment_intents spi
LEFT JOIN entites e ON spi.fk_entite = e.id
LEFT JOIN users u ON spi.fk_user = u.id
WHERE spi.stripe_payment_intent_id = :pi_id"
);
// Récupérer les informations depuis ope_pass
$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
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
WHERE p.stripe_payment_id = :pi_id
");
$stmt->execute(['pi_id' => $paymentIntentId]);
$payment = $stmt->fetch();
if (!$payment) {
$passage = $stmt->fetch();
if (!$passage) {
$this->sendError('Paiement non trouvé', 404);
return;
}
// Vérifier les droits
$userEntityId = Session::getEntityId();
$userId = Session::getUserId();
// Récupérer le rôle depuis la base de données
$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 ($payment['fk_entite'] != $userEntityId &&
$payment['fk_user'] != $userId &&
if ($passage['fk_entite'] != $userEntityId &&
$passage['fk_user'] != $userId &&
$userRole < 3) {
$this->sendError('Non autorisé', 403);
return;
}
// Récupérer le statut en temps réel depuis Stripe
$stripeStatus = $this->stripeService->getPaymentIntentStatus($paymentIntentId);
// Déchiffrer le nom de l'entité si nécessaire
$entiteNom = '';
if (!empty($passage['entite_nom'])) {
try {
$entiteNom = \ApiService::decryptData($passage['entite_nom']);
} catch (Exception $e) {
$entiteNom = 'Entité inconnue';
}
}
$this->sendSuccess([
'payment_intent_id' => $payment['stripe_payment_intent_id'],
'status' => $payment['status'],
'amount' => $payment['amount'],
'currency' => $payment['currency'],
'application_fee' => $payment['application_fee'],
'payment_intent_id' => $paymentIntentId,
'passage_id' => $passage['id'],
'status' => $stripeStatus['status'] ?? 'unknown',
'amount' => (int)($passage['montant'] * 100), // montant en BDD est en euros, on convertit en centimes
'currency' => 'eur',
'entite' => [
'id' => $payment['fk_entite'],
'nom' => $payment['entite_nom']
'id' => $passage['fk_entite'],
'nom' => $entiteNom
],
'user' => [
'id' => $payment['fk_user'],
'nom' => $payment['user_nom'],
'prenom' => $payment['user_prenom']
'id' => $passage['fk_user'],
'nom' => $passage['user_nom'],
'prenom' => $passage['user_prenom']
],
'created_at' => $payment['created_at']
'created_at' => $passage['date_creat'],
'stripe_details' => $stripeStatus
]);
} catch (Exception $e) {
@@ -419,10 +469,11 @@ class StripeController extends Controller {
$platform = $data['platform'] ?? '';
if ($platform === 'ios') {
// Pour iOS, on vérifie côté client (iPhone XS+ avec iOS 15.4+)
// Pour iOS, on vérifie côté client (iPhone XS+ avec iOS 16.4+)
$this->sendSuccess([
'message' => 'Vérification iOS à faire côté client',
'requirements' => 'iPhone XS ou plus récent avec iOS 15.4+'
'requirements' => 'iPhone XS ou plus récent avec iOS 16.4+',
'details' => 'iOS 16.4 minimum requis pour le support PIN complet'
]);
return;
}

View File

@@ -225,36 +225,32 @@ class UserController {
'has_password' => isset($data['password'])
]);
// Validation des données requises
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 requis',
'field' => 'email'
], 400);
return;
}
if (!isset($data['name']) || empty(trim($data['name']))) {
LogService::log('Erreur création utilisateur : Nom manquant', [
// Validation : au moins name OU sect_name requis
if ((!isset($data['name']) || empty(trim($data['name']))) &&
(!isset($data['sect_name']) || empty(trim($data['sect_name'])))) {
LogService::log('Erreur création utilisateur : Aucun nom fourni', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $data['email'] ?? 'non fourni'
'email' => $data['email'] ?? 'non fourni',
'has_name' => isset($data['name']),
'has_sect_name' => isset($data['sect_name'])
]);
Response::json([
'status' => 'error',
'message' => 'Nom requis',
'field' => 'name'
'message' => 'Au moins un nom (name ou sect_name) est requis',
'field' => 'name_or_sect_name'
], 400);
return;
}
$email = trim(strtolower($data['email']));
$name = trim($data['name']);
// L'email est maintenant optionnel
$email = isset($data['email']) && !empty(trim($data['email']))
? trim(strtolower($data['email']))
: '';
// Le name peut être vide si sect_name est fourni
$name = isset($data['name']) && !empty(trim($data['name']))
? trim($data['name'])
: '';
$firstName = isset($data['first_name']) ? trim($data['first_name']) : '';
$role = isset($data['role']) ? (int)$data['role'] : 1;
$entiteId = isset($data['fk_entite']) ? (int)$data['fk_entite'] : 1;
@@ -288,8 +284,8 @@ class UserController {
return;
}
// Validation de l'email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
// Validation de l'email seulement s'il est fourni
if (!empty($email) && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
LogService::log('Erreur création utilisateur : Format email invalide', [
'level' => 'warning',
'createdBy' => $currentUserId,
@@ -305,8 +301,10 @@ class UserController {
}
// Chiffrement des données sensibles
$encryptedEmail = ApiService::encryptSearchableData($email);
$encryptedName = ApiService::encryptData($name);
// Chiffrer l'email seulement s'il n'est pas vide
$encryptedEmail = !empty($email) ? ApiService::encryptSearchableData($email) : null;
// Chiffrer le name seulement s'il n'est pas vide
$encryptedName = !empty($name) ? ApiService::encryptData($name) : null;
// Vérification de l'existence de l'email
// DÉSACTIVÉ : Le client souhaite permettre plusieurs comptes avec le même email
@@ -503,36 +501,43 @@ class UserController {
]);
$userId = $this->db->lastInsertId();
// Envoi des emails séparés pour plus de sécurité
// 1er email : TOUJOURS envoyer l'identifiant (username)
$usernameEmailData = [
'email' => $email,
'username' => $username,
'name' => $name
];
ApiService::sendEmail($email, $name, 'welcome_username', $usernameEmailData);
// 2ème email : Envoyer le mot de passe (toujours, qu'il soit manuel ou généré)
// Attendre un peu entre les deux emails pour éviter qu'ils arrivent dans le mauvais ordre
sleep(1);
$passwordEmailData = [
'email' => $email,
'password' => $password,
'name' => $name
];
ApiService::sendEmail($email, $name, 'welcome_password', $passwordEmailData);
// Envoi des emails séparés pour plus de sécurité (seulement si un email est fourni)
if (!empty($email)) {
// 1er email : Envoyer l'identifiant (username)
$usernameEmailData = [
'email' => $email,
'username' => $username,
'name' => $name
];
ApiService::sendEmail($email, $name, 'welcome_username', $usernameEmailData);
// 2ème email : Envoyer le mot de passe (toujours, qu'il soit manuel ou généré)
// Attendre un peu entre les deux emails pour éviter qu'ils arrivent dans le mauvais ordre
sleep(1);
$passwordEmailData = [
'email' => $email,
'password' => $password,
'name' => $name
];
ApiService::sendEmail($email, $name, 'welcome_password', $passwordEmailData);
} else {
LogService::log('Utilisateur créé sans email - pas d\'envoi de credentials', [
'level' => 'info',
'userId' => $userId,
'username' => $username
]);
}
LogService::log('Utilisateur GeoSector créé', [
'level' => 'info',
'createdBy' => $currentUserId,
'newUserId' => $userId,
'email' => $email,
'email' => !empty($email) ? $email : 'non fourni',
'username' => $username,
'usernameManual' => $chkUsernameManuel === 1 ? 'oui' : 'non',
'passwordManual' => $chkMdpManuel === 1 ? 'oui' : 'non',
'emailsSent' => '2 emails (username + password)'
'emailsSent' => !empty($email) ? '2 emails (username + password)' : 'aucun (pas d\'email)'
]);
// Préparer la réponse avec les informations de connexion si générées automatiquement
@@ -639,6 +644,58 @@ class UserController {
$params['encrypted_mobile'] = ApiService::encryptData(trim($data['mobile']));
}
// Gestion de la modification du username
if (isset($data['username'])) {
$username = trim($data['username']);
// Validation de la longueur en caractères UTF-8
$usernameLength = mb_strlen($username, 'UTF-8');
if ($usernameLength < 8) {
Response::json([
'status' => 'error',
'message' => 'Identifiant trop court',
'field' => 'username',
'details' => 'Minimum 8 caractères'
], 400);
return;
}
if ($usernameLength > 30) {
Response::json([
'status' => 'error',
'message' => 'Identifiant trop long',
'field' => 'username',
'details' => 'Maximum 30 caractères'
], 400);
return;
}
$encryptedUsername = ApiService::encryptSearchableData($username);
// Vérifier l'unicité du username (sauf pour l'utilisateur courant)
$checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypted_user_name = ? AND id != ?');
$checkStmt->execute([$encryptedUsername, $id]);
if ($checkStmt->fetch()) {
Response::json([
'status' => 'error',
'message' => 'Cet identifiant est déjà utilisé par un autre utilisateur',
'field' => 'username'
], 409);
return;
}
$updateFields[] = "encrypted_user_name = :encrypted_user_name";
$params['encrypted_user_name'] = $encryptedUsername;
LogService::log('Modification du username', [
'level' => 'info',
'userId' => $id,
'newUsername' => $username,
'modifiedBy' => $currentUserId
]);
}
// Traitement des champs non chiffrés
$nonEncryptedFields = [
'first_name',
@@ -1142,6 +1199,209 @@ class UserController {
}
}
/**
* Enregistre les informations du device de l'utilisateur
* POST /api/users/device-info
*/
public function saveDeviceInfo(): void {
Session::requireAuth();
$userId = Session::getUserId();
if (!$userId) {
LogService::log('Device info error: Invalid session', [
'level' => 'error',
'session_id' => session_id()
]);
Response::json([
'status' => 'error',
'message' => 'Session invalide'
], 401);
return;
}
// Récupération des données du payload
$data = Request::getJson();
// Validation des données requises
if (empty($data['platform'])) {
LogService::log('Device info error: Missing platform', [
'level' => 'error',
'user_id' => $userId,
'data' => $data
]);
Response::json([
'status' => 'error',
'message' => 'Platform requis',
'field' => 'platform'
], 400);
return;
}
// Validation des IPs (IPv4 uniquement)
// Pour la plateforme Web, device_ip_local contient "Web Platform" → on le traite comme NULL
$deviceIpLocal = $data['device_ip_local'] ?? null;
$deviceIpPublic = $data['device_ip_public'] ?? null;
if ($deviceIpLocal && !filter_var($deviceIpLocal, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
LogService::log('Device IP local invalide, ignorée', [
'level' => 'debug',
'user_id' => $userId,
'device_ip_local' => $deviceIpLocal
]);
$deviceIpLocal = null; // Ignorer si ce n'est pas une IPv4 valide
}
if ($deviceIpPublic && !filter_var($deviceIpPublic, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
LogService::log('Device IP public invalide, ignorée', [
'level' => 'debug',
'user_id' => $userId,
'device_ip_public' => $deviceIpPublic
]);
$deviceIpPublic = null; // Ignorer si ce n'est pas une IPv4 valide
}
// Validation du niveau de batterie (0-100)
$batteryLevel = isset($data['battery_level']) ? intval($data['battery_level']) : null;
if ($batteryLevel !== null && ($batteryLevel < 0 || $batteryLevel > 100)) {
$batteryLevel = null;
}
// Conversion des booléens
$nfcCapable = isset($data['device_nfc_capable']) ? ($data['device_nfc_capable'] ? 1 : 0) : null;
$tapToPay = isset($data['device_supports_tap_to_pay']) ? ($data['device_supports_tap_to_pay'] ? 1 : 0) : null;
$batteryCharging = isset($data['battery_charging']) ? ($data['battery_charging'] ? 1 : 0) : null;
// Conversion de la date de check
$lastDeviceInfoCheck = null;
if (!empty($data['last_device_info_check'])) {
try {
$date = new \DateTime($data['last_device_info_check']);
$lastDeviceInfoCheck = $date->format('Y-m-d H:i:s');
} catch (\Exception $e) {
// Ignorer si le format de date est invalide
}
}
try {
$this->db->beginTransaction();
// Pour les plateformes Web sans device_identifier, générer un identifiant unique basé sur user_id + platform
$deviceIdentifier = $data['device_identifier'] ?? null;
// Si device_identifier est vide ou NULL pour la plateforme Web, générer un identifiant
if (empty($deviceIdentifier)) {
$platform = $data['platform'] ?? 'unknown';
if ($platform === 'Web') {
// Un seul device Web par utilisateur
$deviceIdentifier = 'web_' . $userId . '_' . md5($userId . '_web');
} else {
// Pour les autres plateformes sans identifier, générer un identifiant aléatoire
$deviceIdentifier = strtolower($platform) . '_' . $userId . '_' . uniqid();
}
LogService::log('Device identifier généré automatiquement', [
'level' => 'debug',
'user_id' => $userId,
'platform' => $platform,
'device_identifier' => $deviceIdentifier
]);
}
// Requête INSERT ... ON DUPLICATE KEY UPDATE
$sql = "INSERT INTO user_devices (
fk_user, platform, device_model, device_name, device_manufacturer,
device_identifier, device_ip_local, device_ip_public, device_wifi_name,
device_wifi_bssid, ios_version, device_nfc_capable, device_supports_tap_to_pay,
battery_level, battery_charging, battery_state, app_version, app_build,
last_device_info_check
) VALUES (
:fk_user, :platform, :device_model, :device_name, :device_manufacturer,
:device_identifier, :device_ip_local, :device_ip_public, :device_wifi_name,
:device_wifi_bssid, :ios_version, :device_nfc_capable, :device_supports_tap_to_pay,
:battery_level, :battery_charging, :battery_state, :app_version, :app_build,
:last_device_info_check
) ON DUPLICATE KEY UPDATE
platform = VALUES(platform),
device_model = VALUES(device_model),
device_name = VALUES(device_name),
device_manufacturer = VALUES(device_manufacturer),
device_ip_local = VALUES(device_ip_local),
device_ip_public = VALUES(device_ip_public),
device_wifi_name = VALUES(device_wifi_name),
device_wifi_bssid = VALUES(device_wifi_bssid),
ios_version = VALUES(ios_version),
device_nfc_capable = VALUES(device_nfc_capable),
device_supports_tap_to_pay = VALUES(device_supports_tap_to_pay),
battery_level = VALUES(battery_level),
battery_charging = VALUES(battery_charging),
battery_state = VALUES(battery_state),
app_version = VALUES(app_version),
app_build = VALUES(app_build),
last_device_info_check = VALUES(last_device_info_check),
updated_at = CURRENT_TIMESTAMP";
$stmt = $this->db->prepare($sql);
$params = [
':fk_user' => $userId,
':platform' => $data['platform'] ?? null,
':device_model' => $data['device_model'] ?? null,
':device_name' => $data['device_name'] ?? null,
':device_manufacturer' => $data['device_manufacturer'] ?? null,
':device_identifier' => $deviceIdentifier,
':device_ip_local' => $deviceIpLocal,
':device_ip_public' => $deviceIpPublic,
':device_wifi_name' => $data['device_wifi_name'] ?? null,
':device_wifi_bssid' => $data['device_wifi_bssid'] ?? null,
':ios_version' => $data['ios_version'] ?? null,
':device_nfc_capable' => $nfcCapable,
':device_supports_tap_to_pay' => $tapToPay,
':battery_level' => $batteryLevel,
':battery_charging' => $batteryCharging,
':battery_state' => $data['battery_state'] ?? null,
':app_version' => $data['app_version'] ?? null,
':app_build' => $data['app_build'] ?? null,
':last_device_info_check' => $lastDeviceInfoCheck
];
$stmt->execute($params);
$this->db->commit();
LogService::log('Device info enregistrées avec succès', [
'level' => 'info',
'user_id' => $userId,
'platform' => $data['platform'],
'device_identifier' => $deviceIdentifier
]);
Response::json([
'status' => 'success',
'message' => 'Informations du device enregistrées'
], 200);
} catch (\PDOException $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
LogService::log('Error saving device info', [
'level' => 'error',
'user_id' => $userId,
'error' => $e->getMessage(),
'code' => $e->getCode(),
'platform' => $data['platform'] ?? 'unknown'
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de l\'enregistrement des informations du device',
'details' => $e->getMessage()
], 500);
}
}
// Méthodes auxiliaires
private function validateUpdateData(array $data): ?string {
// Validation de l'email

View File

@@ -37,6 +37,7 @@ class Router {
// 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->post('users/device-info', ['UserController', 'saveDeviceInfo']); // Endpoint pour sauvegarder les infos du device
$this->get('users', ['UserController', 'getUsers']);
$this->get('users/:id', ['UserController', 'getUserById']);
$this->post('users', ['UserController', 'createUser']);
@@ -44,6 +45,7 @@ class Router {
$this->delete('users/:id', ['UserController', 'deleteUser']);
$this->post('users/:id/reset-password', ['UserController', 'resetPassword']);
$this->post('logout', ['LoginController', 'logout']);
$this->get('user/session', ['LoginController', 'refreshSession']);
// Routes entités
$this->get('entites', ['EntiteController', 'getEntites']);
@@ -128,21 +130,19 @@ class Router {
$this->post('stripe/accounts', ['StripeController', 'createAccount']);
$this->get('stripe/accounts/:entityId/status', ['StripeController', 'getAccountStatus']);
$this->post('stripe/accounts/:accountId/onboarding-link', ['StripeController', 'createOnboardingLink']);
$this->post('stripe/locations', ['StripeController', 'createLocation']);
// Terminal et Tap to Pay
$this->post('stripe/terminal/connection-token', ['StripeController', 'createConnectionToken']);
// Tap to Pay - Vérification compatibilité
$this->post('stripe/devices/check-tap-to-pay', ['StripeController', 'checkTapToPayCapability']);
$this->get('stripe/devices/certified-android', ['StripeController', 'getCertifiedAndroidDevices']);
// Paiements
$this->post('stripe/payments/create-intent', ['StripeController', 'createPaymentIntent']);
$this->get('stripe/payments/:paymentIntentId', ['StripeController', 'getPaymentStatus']);
// Statistiques et configuration
$this->get('stripe/stats', ['StripeController', 'getPaymentStats']);
$this->get('stripe/config', ['StripeController', 'getPublicConfig']);
// Webhook (IMPORTANT: pas d'authentification requise pour les webhooks Stripe)
$this->post('stripe/webhook', ['StripeWebhookController', 'handleWebhook']);
}

View File

@@ -14,26 +14,125 @@ use Database;
* Bloque temporairement ou définitivement des adresses IP suspectes
*/
class IPBlocker {
private static ?PDO $db = null;
private static array $cache = [];
private static ?int $lastCacheClean = null;
// IPs en whitelist (jamais bloquées)
private static ?array $dynamicWhitelist = null;
private static ?int $whitelistLastCheck = null;
// IPs en whitelist statique (jamais bloquées)
const WHITELIST = [
'127.0.0.1',
'::1',
'localhost'
];
// Configuration pour récupérer l'IP depuis IN3
const IN3_CONFIG = [
'host' => '195.154.80.116',
'user' => 'root',
'ip_file' => '/var/bat/IP',
'cache_duration' => 3600, // 1 heure de cache
'local_cache_file' => __DIR__ . '/../../../config/whitelist_ip_cache.txt'
];
/**
* Récupérer la whitelist dynamique depuis IN3
*/
private static function getDynamicWhitelist(): array {
$now = time();
// Vérifier le cache en mémoire (valide 1 heure)
if (self::$dynamicWhitelist !== null &&
self::$whitelistLastCheck !== null &&
($now - self::$whitelistLastCheck) < self::IN3_CONFIG['cache_duration']) {
return self::$dynamicWhitelist;
}
$dynamicIps = [];
// D'abord, essayer de lire le cache local
$cacheFile = self::IN3_CONFIG['local_cache_file'];
if (file_exists($cacheFile)) {
$cacheData = json_decode(file_get_contents($cacheFile), true);
if ($cacheData && isset($cacheData['ip']) && isset($cacheData['timestamp'])) {
// Si le cache a moins d'1 heure, l'utiliser
if (($now - $cacheData['timestamp']) < self::IN3_CONFIG['cache_duration']) {
$dynamicIps[] = $cacheData['ip'];
self::$dynamicWhitelist = $dynamicIps;
self::$whitelistLastCheck = $now;
return $dynamicIps;
}
}
}
// Sinon, récupérer depuis IN3 via SSH
try {
$command = sprintf(
'ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no %s@%s "cat %s 2>/dev/null"',
escapeshellarg(self::IN3_CONFIG['user']),
escapeshellarg(self::IN3_CONFIG['host']),
escapeshellarg(self::IN3_CONFIG['ip_file'])
);
$output = shell_exec($command);
if ($output) {
$ip = trim($output);
// Valider que c'est une IP valide
if (filter_var($ip, FILTER_VALIDATE_IP)) {
$dynamicIps[] = $ip;
// Sauvegarder dans le cache local
$cacheDir = dirname($cacheFile);
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
file_put_contents($cacheFile, json_encode([
'ip' => $ip,
'timestamp' => $now,
'retrieved_at' => date('Y-m-d H:i:s')
]));
error_log("Whitelist IP mise à jour depuis IN3: $ip");
}
}
} catch (\Exception $e) {
error_log("Erreur lors de la récupération de l'IP depuis IN3: " . $e->getMessage());
// En cas d'erreur, utiliser le cache local même s'il est expiré
if (file_exists($cacheFile)) {
$cacheData = json_decode(file_get_contents($cacheFile), true);
if ($cacheData && isset($cacheData['ip'])) {
$dynamicIps[] = $cacheData['ip'];
error_log("Utilisation du cache local expiré: " . $cacheData['ip']);
}
}
}
self::$dynamicWhitelist = $dynamicIps;
self::$whitelistLastCheck = $now;
return $dynamicIps;
}
/**
* Vérifier si une IP est bloquée
*/
public static function isBlocked(string $ip): bool {
// Vérifier la whitelist
// Vérifier la whitelist statique
if (in_array($ip, self::WHITELIST)) {
return false;
}
// Vérifier la whitelist dynamique depuis IN3
$dynamicWhitelist = self::getDynamicWhitelist();
if (in_array($ip, $dynamicWhitelist)) {
return false;
}
// Vérifier le cache en mémoire
if (isset(self::$cache[$ip])) {
@@ -322,7 +421,33 @@ class IPBlocker {
return false;
}
}
/**
* Vérifier si une IP est dans la whitelist (méthode publique)
*/
public static function isWhitelisted(string $ip): bool {
// Vérifier la whitelist statique
if (in_array($ip, self::WHITELIST)) {
return true;
}
// Vérifier la whitelist dynamique
$dynamicWhitelist = self::getDynamicWhitelist();
return in_array($ip, $dynamicWhitelist);
}
/**
* Forcer la mise à jour de la whitelist dynamique
*/
public static function refreshDynamicWhitelist(): array {
// Forcer l'expiration du cache
self::$whitelistLastCheck = 0;
self::$dynamicWhitelist = null;
// Récupérer la nouvelle whitelist
return self::getDynamicWhitelist();
}
/**
* Obtenir la liste des IPs bloquées
*/

View File

@@ -463,7 +463,7 @@ class StripeService {
'success' => true,
'tap_to_pay_supported' => false,
'message' => 'Appareil non certifié pour Tap to Pay en France',
'alternative' => 'Utilisez un iPhone XS ou plus récent avec iOS 15.4+'
'alternative' => 'Utilisez un iPhone XS ou plus récent avec iOS 16.4+'
];
} catch (Exception $e) {