update
This commit is contained in:
@@ -46,6 +46,149 @@ final class AgentModel extends BaseModel
|
||||
return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bornes du contrat, telles qu'elles servent aux contrôles d'affectation.
|
||||
*
|
||||
* Les deux bornes sont FACULTATIVES et n'ont pas le même sens quand elles
|
||||
* manquent :
|
||||
* - pas de date de début : contrat d'origine inconnue (fiche ancienne), on
|
||||
* n'oppose alors aucune borne basse plutôt que d'inventer une date ;
|
||||
* - pas de date de fin : contrat à durée indéterminée, la borne haute
|
||||
* n'existe pas.
|
||||
* Dans les deux cas, l'absence ouvre — elle ne ferme jamais.
|
||||
*
|
||||
* @param array<string, mixed> $agent Fiche agent, telle que renvoyée par
|
||||
* findActive() ou allActive().
|
||||
*
|
||||
* @return array{0: string|null, 1: string|null} [début, fin] au format AAAA-MM-JJ.
|
||||
*/
|
||||
public static function contractRange(array $agent): array
|
||||
{
|
||||
$debut = trim((string) ($agent['date_debut_contrat'] ?? ''));
|
||||
$fin = trim((string) ($agent['date_fin_contrat'] ?? ''));
|
||||
|
||||
return [$debut !== '' ? $debut : null, $fin !== '' ? $fin : null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si la date tombe dans les bornes du contrat.
|
||||
*
|
||||
* Comparaison de chaînes « AAAA-MM-JJ » : ce format se compare
|
||||
* lexicographiquement dans le même ordre que chronologiquement, ce qui évite
|
||||
* de construire deux objets DateTime par créneau contrôlé.
|
||||
*/
|
||||
public static function contractCoversDate(array $agent, string $date): bool
|
||||
{
|
||||
[$debut, $fin] = self::contractRange($agent);
|
||||
|
||||
if ($debut !== null && $date < $debut) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !($fin !== null && $date > $fin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre les dates qui tombent hors contrat, dédoublonnées et triées.
|
||||
*
|
||||
* @param array<int, string> $dates
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function datesOutsideContract(array $agent, array $dates): array
|
||||
{
|
||||
$hors = [];
|
||||
foreach ($dates as $date) {
|
||||
$date = trim((string) $date);
|
||||
if ($date !== '' && !self::contractCoversDate($agent, $date)) {
|
||||
$hors[$date] = true;
|
||||
}
|
||||
}
|
||||
$hors = array_keys($hors);
|
||||
sort($hors);
|
||||
|
||||
return $hors;
|
||||
}
|
||||
|
||||
/** « du 01/09/2026 au 31/08/2027 », « à partir du 01/09/2026 », « jusqu'au … ». */
|
||||
public static function contractPeriodLabel(array $agent): string
|
||||
{
|
||||
[$debut, $fin] = self::contractRange($agent);
|
||||
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
|
||||
|
||||
if ($debut !== null && $fin !== null) {
|
||||
return sprintf('du %s au %s', $lisible($debut), $lisible($fin));
|
||||
}
|
||||
if ($debut !== null) {
|
||||
return sprintf('à partir du %s', $lisible($debut));
|
||||
}
|
||||
if ($fin !== null) {
|
||||
return sprintf('jusqu’au %s', $lisible($fin));
|
||||
}
|
||||
|
||||
return 'sans date de contrat renseignée';
|
||||
}
|
||||
|
||||
/**
|
||||
* Message de refus commun à tous les points d'écriture, pour que
|
||||
* l'utilisateur lise la même phrase quel que soit l'écran qui l'a produit.
|
||||
*
|
||||
* @param array<int, string> $datesHorsContrat
|
||||
*/
|
||||
public static function contractViolationMessage(array $agent, array $datesHorsContrat): string
|
||||
{
|
||||
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
|
||||
$exemples = array_map($lisible, array_slice($datesHorsContrat, 0, 3));
|
||||
if (count($datesHorsContrat) > 3) {
|
||||
$exemples[] = sprintf('et %d autre(s)', count($datesHorsContrat) - 3);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s %s n’est pas sous contrat à cette période : son contrat court %s. Date(s) refusée(s) : %s.',
|
||||
(string) ($agent['prenom'] ?? ''),
|
||||
(string) ($agent['nom'] ?? ''),
|
||||
self::contractPeriodLabel($agent),
|
||||
implode(', ', $exemples)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Créneaux déjà planifiés en dehors d'une fenêtre de contrat.
|
||||
*
|
||||
* Sert au contrôle MIROIR de l'affectation : raccourcir un contrat ne doit
|
||||
* pas laisser derrière lui des affectations orphelines. Interroge donc les
|
||||
* créneaux, pas les plannings — un planning peut être à cheval sur la borne.
|
||||
*
|
||||
* @return array{nombre: int, premiere: string|null, derniere: string|null}
|
||||
*/
|
||||
public function entriesOutsideContract(int $agentId, ?string $debut, ?string $fin): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT COUNT(*) AS nombre, MIN(c.date_jour) AS premiere, MAX(c.date_jour) AS derniere
|
||||
FROM creneau_horaire c
|
||||
JOIN planning p ON p.id_planning = c.id_planning
|
||||
WHERE p.id_agent = :agent_id
|
||||
AND ((:debut IS NOT NULL AND c.date_jour < :debut_compare)
|
||||
OR (:fin IS NOT NULL AND c.date_jour > :fin_compare))'
|
||||
);
|
||||
// Chaque paramètre nommé est répété sous deux noms : PDO tourne sans
|
||||
// émulation, un même nom ne peut pas apparaître deux fois.
|
||||
$stmt->execute([
|
||||
'agent_id' => $agentId,
|
||||
'debut' => $debut,
|
||||
'debut_compare' => $debut,
|
||||
'fin' => $fin,
|
||||
'fin_compare' => $fin,
|
||||
]);
|
||||
$ligne = $stmt->fetch() ?: [];
|
||||
|
||||
return [
|
||||
'nombre' => (int) ($ligne['nombre'] ?? 0),
|
||||
'premiere' => $ligne['premiere'] ?? null,
|
||||
'derniere' => $ligne['derniere'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection commune à toutes les lectures d'agent, complétée par la clause
|
||||
* WHERE de chaque appelant.
|
||||
@@ -375,6 +518,10 @@ final class AgentModel extends BaseModel
|
||||
* bindValue() explicite ici (et non un tableau passé à execute()) car
|
||||
* `structure_id` peut être NULL et exige PDO::PARAM_NULL.
|
||||
*
|
||||
* Le nom et le prénom font partie des champs modifiables depuis la V7.13 ;
|
||||
* le MATRICULE, lui, ne l'est jamais : c'est la clé de rapprochement avec
|
||||
* les outils RH, et les plannings comme les PDF déjà produits s'y réfèrent.
|
||||
*
|
||||
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
|
||||
*
|
||||
* @throws \RuntimeException Si l'agent n'existe pas.
|
||||
@@ -395,7 +542,9 @@ final class AgentModel extends BaseModel
|
||||
|
||||
$update = $this->pdo->prepare(
|
||||
'UPDATE agent
|
||||
SET email = :email,
|
||||
SET nom = :nom,
|
||||
prenom = :prenom,
|
||||
email = :email,
|
||||
telephone = :telephone,
|
||||
adresse = :adresse,
|
||||
est_diplome = :est_diplome,
|
||||
@@ -410,6 +559,8 @@ final class AgentModel extends BaseModel
|
||||
WHERE id_agent = :agent_id'
|
||||
);
|
||||
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
|
||||
$update->bindValue(':nom', $data['nom']);
|
||||
$update->bindValue(':prenom', $data['prenom']);
|
||||
$update->bindValue(':email', $data['email']);
|
||||
$update->bindValue(':telephone', $data['telephone']);
|
||||
$update->bindValue(':adresse', $data['adresse']);
|
||||
|
||||
672
app/Models/CompteModel.php
Normal file
672
app/Models/CompteModel.php
Normal file
@@ -0,0 +1,672 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Access;
|
||||
use DomainException;
|
||||
|
||||
/**
|
||||
* Comptes d'accès et jetons (V7.11).
|
||||
*
|
||||
* PRINCIPE : chaque utilisateur — agent, responsable de lieu, Service Enfance,
|
||||
* DSI — reçoit un jeton unique tiré au sort par le serveur. Il n'y a AUCUN mot
|
||||
* de passe dans PTA : rien à choisir, rien à renouveler, rien à ressaisir sur
|
||||
* un post-it.
|
||||
*
|
||||
* Le jeton n'est stocké NULLE PART en clair. La base garde son empreinte
|
||||
* SHA-256 et ses onze premiers caractères, qui servent uniquement à reconnaître
|
||||
* une ligne dans la liste de la page DSI. Conséquence directe, à assumer dans
|
||||
* l'interface : un jeton n'est affiché QU'UNE FOIS, au moment où il est produit.
|
||||
* Perdu, il ne se retrouve pas — il se régénère, ce qui invalide le précédent.
|
||||
*
|
||||
* Pourquoi SHA-256 sans sel, alors qu'on hache d'ordinaire les secrets avec
|
||||
* password_hash() : un jeton PTA est tiré sur 120 bits d'entropie par le
|
||||
* serveur, il n'est pas devinable par dictionnaire comme le serait un mot de
|
||||
* passe choisi par un humain. Le durcissement lent de password_hash() n'apporte
|
||||
* donc rien ici, alors qu'il interdirait de retrouver le compte en une seule
|
||||
* lecture d'index — il faudrait comparer le jeton présenté à CHAQUE compte.
|
||||
*/
|
||||
final class CompteModel extends BaseModel
|
||||
{
|
||||
/**
|
||||
* Alphabet des jetons : chiffres et majuscules, MOINS I, O, 0 et 1.
|
||||
* Les jetons sont recopiés à la main depuis un courriel ou un papier : les
|
||||
* caractères qu'on confond à la lecture en sont exclus.
|
||||
*/
|
||||
private const ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
|
||||
/** 4 groupes de 6 caractères = 24 tirages sur 32 valeurs, soit 120 bits. */
|
||||
private const GROUPES = 4;
|
||||
private const TAILLE_GROUPE = 6;
|
||||
|
||||
/**
|
||||
* Longueur minimale d'un mot de passe. La longueur prime sur la complexité :
|
||||
* imposer une majuscule et un chiffre produit « Motdepasse1 », pas un secret.
|
||||
*/
|
||||
public const MOT_DE_PASSE_MINIMUM = 12;
|
||||
|
||||
/**
|
||||
* Longueur maximale, imposée par bcrypt : password_hash() ne considère que
|
||||
* les 72 PREMIERS OCTETS et ignore le reste EN SILENCE. Refuser au-delà vaut
|
||||
* mieux que laisser croire qu'une phrase de 90 caractères protège mieux
|
||||
* qu'un préfixe de 72.
|
||||
*/
|
||||
public const MOT_DE_PASSE_MAXIMUM = 72;
|
||||
|
||||
/**
|
||||
* Empreinte du jeton d'installation posé par la migration V7.11.
|
||||
* Sert uniquement à afficher l'alerte « ce jeton est public, régénérez-le »
|
||||
* tant que le compte DSI initial n'a pas été renouvelé.
|
||||
*/
|
||||
public const EMPREINTE_INSTALLATION = '7f61ffc8efed4ed46d581a98286115ad03bb0122ffc70db3398584348aca431d';
|
||||
|
||||
/**
|
||||
* Vérifie un jeton présenté à la connexion.
|
||||
*
|
||||
* La vue ne renvoie le compte que s'il est actif ET que l'identité derrière
|
||||
* lui l'est aussi : un agent désactivé ne peut plus entrer, même avec un
|
||||
* jeton techniquement valide.
|
||||
*
|
||||
* @return array<string, mixed>|null Le compte, ou null si le jeton est
|
||||
* inconnu, révoqué ou porté par une
|
||||
* identité inactive.
|
||||
*/
|
||||
public function authentifier(string $jeton): ?array
|
||||
{
|
||||
$jeton = self::normaliser($jeton);
|
||||
if ($jeton === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT * FROM v_acces_compte
|
||||
WHERE jeton_empreinte = :empreinte AND actif = 1 AND identite_active = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['empreinte' => self::empreinte($jeton)]);
|
||||
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit un compte par son identifiant, pour la SECONDE étape de connexion.
|
||||
*
|
||||
* Les mêmes conditions qu'à l'authentification par jeton s'appliquent — actif
|
||||
* et identité active : entre la saisie du jeton et celle du mot de passe, la
|
||||
* DSI a pu révoquer le compte. La ligne est renvoyée brute, avec son hachage,
|
||||
* là où trouver() la met en forme pour l'API et le retire.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function trouverPourAuthentification(int $compteId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT * FROM v_acces_compte
|
||||
WHERE id_compte = :id AND actif = 1 AND identite_active = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/** Horodate une connexion réussie et incrémente son compteur. */
|
||||
public function enregistrerConnexion(int $compteId): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE compte_acces
|
||||
SET date_derniere_connexion = NOW(),
|
||||
nombre_connexions = nombre_connexions + 1
|
||||
WHERE id_compte = :id'
|
||||
);
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des comptes pour la page DSI.
|
||||
*
|
||||
* @param array<string, mixed> $filtres role, actif (0|1), q (texte libre).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function lister(array $filtres = []): array
|
||||
{
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
|
||||
$role = (string) ($filtres['role'] ?? '');
|
||||
if ($role !== '') {
|
||||
$conditions[] = 'role = :role';
|
||||
$params['role'] = $role;
|
||||
}
|
||||
|
||||
if (isset($filtres['actif']) && $filtres['actif'] !== '') {
|
||||
$conditions[] = 'actif = :actif';
|
||||
$params['actif'] = (int) $filtres['actif'];
|
||||
}
|
||||
|
||||
$recherche = trim((string) ($filtres['q'] ?? ''));
|
||||
if ($recherche !== '') {
|
||||
$conditions[] = '(identite_libelle LIKE :q OR libelle LIKE :q OR matricule LIKE :q OR jeton_prefixe LIKE :q)';
|
||||
$params['q'] = '%' . $recherche . '%';
|
||||
}
|
||||
|
||||
$where = $conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_compte, role, libelle, identite_libelle, identite_active, id_agent, id_structure,
|
||||
matricule, structure_nom, jeton_prefixe, jeton_empreinte, actif, commentaire,
|
||||
date_creation, date_rotation, date_derniere_connexion, nombre_connexions,
|
||||
mot_de_passe_requis, mot_de_passe_defini, date_mot_de_passe
|
||||
FROM v_acces_compte' . $where . '
|
||||
ORDER BY FIELD(role, \'DSI\', \'SERVICE_ENFANCE\', \'RESPONSABLE_STRUCTURE\', \'AGENT\'), identite_libelle'
|
||||
);
|
||||
$stmt->execute($params);
|
||||
|
||||
return array_map(
|
||||
fn (array $ligne): array => $this->presenter($ligne),
|
||||
$stmt->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un compte par son identifiant.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function trouver(int $compteId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM v_acces_compte WHERE id_compte = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
$compte = $stmt->fetch();
|
||||
|
||||
return $compte === false ? null : $this->presenter($compte);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identités actives encore dépourvues de compte.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function manquants(): array
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT role, id_agent, id_structure, libelle FROM v_acces_compte_manquant ORDER BY role, libelle'
|
||||
);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un compte et retourne le jeton EN CLAIR — unique occasion de le lire.
|
||||
*
|
||||
* @return array{compte: array<string, mixed>, jeton: string}
|
||||
*
|
||||
* @throws DomainException Si le rôle, l'identité ou l'unicité ne conviennent pas.
|
||||
*/
|
||||
public function creer(
|
||||
string $role,
|
||||
?int $agentId,
|
||||
?int $structureId,
|
||||
string $libelle,
|
||||
?string $commentaire = null
|
||||
): array {
|
||||
$role = strtoupper(trim($role));
|
||||
$libelle = trim($libelle);
|
||||
|
||||
if (!in_array($role, [Access::ROLE_AGENT, Access::ROLE_RESPONSABLE, Access::ROLE_SERVICE, Access::ROLE_DSI], true)) {
|
||||
throw new DomainException('Rôle inconnu.');
|
||||
}
|
||||
|
||||
// Un compte AGENT ou RESPONSABLE_STRUCTURE tire son périmètre de son
|
||||
// identité : sans identité, la session n'aurait aucun lieu ni aucun
|
||||
// agent à borner. Les deux autres rôles n'ont pas de périmètre à borner.
|
||||
if ($role === Access::ROLE_AGENT) {
|
||||
$structureId = null;
|
||||
if ($agentId === null) {
|
||||
throw new DomainException('Choisissez l’agent titulaire du jeton.');
|
||||
}
|
||||
$libelle = $this->libelleAgent($agentId);
|
||||
} elseif ($role === Access::ROLE_RESPONSABLE) {
|
||||
$agentId = null;
|
||||
if ($structureId === null) {
|
||||
throw new DomainException('Choisissez le lieu d’affectation titulaire du jeton.');
|
||||
}
|
||||
$libelle = $this->libelleStructure($structureId);
|
||||
} else {
|
||||
$agentId = null;
|
||||
$structureId = null;
|
||||
if ($libelle === '') {
|
||||
throw new DomainException('Indiquez le nom du titulaire du jeton.');
|
||||
}
|
||||
}
|
||||
|
||||
$jeton = self::genererJeton();
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO compte_acces (role, libelle, id_agent, id_structure, jeton_empreinte, jeton_prefixe, commentaire)
|
||||
VALUES (:role, :libelle, :id_agent, :id_structure, :empreinte, :prefixe, :commentaire)'
|
||||
);
|
||||
|
||||
try {
|
||||
$stmt->execute([
|
||||
'role' => $role,
|
||||
'libelle' => mb_substr($libelle, 0, 180),
|
||||
'id_agent' => $agentId,
|
||||
'id_structure' => $structureId,
|
||||
'empreinte' => self::empreinte($jeton),
|
||||
'prefixe' => self::prefixe($jeton),
|
||||
'commentaire' => $commentaire !== null && trim($commentaire) !== ''
|
||||
? mb_substr(trim($commentaire), 0, 255)
|
||||
: null,
|
||||
]);
|
||||
} catch (\PDOException $e) {
|
||||
// 23000 = violation de contrainte : les index uniques sur id_agent et
|
||||
// id_structure garantissent un seul compte par identité.
|
||||
if ($e->getCode() === '23000') {
|
||||
throw new DomainException('Cette identité possède déjà un compte d’accès.');
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$compte = $this->trouver((int) $this->pdo->lastInsertId());
|
||||
|
||||
return ['compte' => $compte ?? [], 'jeton' => $jeton];
|
||||
}
|
||||
|
||||
/**
|
||||
* Régénère le jeton d'un compte : l'ancien cesse immédiatement de
|
||||
* fonctionner, y compris pour les sessions ouvertes — Access relit le compte
|
||||
* à chaque requête, mais c'est l'empreinte qui a changé, pas le compte.
|
||||
*
|
||||
* @return string Le nouveau jeton, en clair, à transmettre au titulaire.
|
||||
*
|
||||
* @throws DomainException Si le compte n'existe pas.
|
||||
*/
|
||||
public function regenererJeton(int $compteId): string
|
||||
{
|
||||
if ($this->trouver($compteId) === null) {
|
||||
throw new DomainException('Compte introuvable.');
|
||||
}
|
||||
|
||||
$jeton = self::genererJeton();
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE compte_acces
|
||||
SET jeton_empreinte = :empreinte,
|
||||
jeton_prefixe = :prefixe,
|
||||
date_rotation = NOW()
|
||||
WHERE id_compte = :id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'empreinte' => self::empreinte($jeton),
|
||||
'prefixe' => self::prefixe($jeton),
|
||||
'id' => $compteId,
|
||||
]);
|
||||
|
||||
return $jeton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active ou désactive un compte.
|
||||
*
|
||||
* @throws DomainException Si l'opération laisserait l'application sans
|
||||
* aucune DSI en état de se connecter.
|
||||
*/
|
||||
public function definirActif(int $compteId, bool $actif): void
|
||||
{
|
||||
$compte = $this->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
throw new DomainException('Compte introuvable.');
|
||||
}
|
||||
|
||||
if (!$actif) {
|
||||
$this->assurerDerniereDsi($compte);
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare('UPDATE compte_acces SET actif = :actif WHERE id_compte = :id');
|
||||
$stmt->execute(['actif' => $actif ? 1 : 0, 'id' => $compteId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime définitivement un compte. Les traces qu'il a laissées dans le
|
||||
* journal SURVIVENT : la clé étrangère est en ON DELETE SET NULL et le
|
||||
* libellé de l'acteur y est figé.
|
||||
*
|
||||
* @throws DomainException Si c'est le dernier accès DSI actif.
|
||||
*/
|
||||
public function supprimer(int $compteId): void
|
||||
{
|
||||
$compte = $this->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
throw new DomainException('Compte introuvable.');
|
||||
}
|
||||
|
||||
$this->assurerDerniereDsi($compte);
|
||||
|
||||
$stmt = $this->pdo->prepare('DELETE FROM compte_acces WHERE id_compte = :id');
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère en une passe un jeton pour chaque agent et chaque lieu actif qui
|
||||
* n'en a pas. C'est l'outil de MISE EN SERVICE : sans lui, il faudrait créer
|
||||
* les comptes un par un avant que quiconque puisse se connecter.
|
||||
*
|
||||
* @return array<int, array{role: string, libelle: string, jeton: string, id_compte: int}>
|
||||
* Les jetons produits, en clair. Cette liste est la seule occasion
|
||||
* de les lire : elle doit être distribuée puis refermée.
|
||||
*/
|
||||
public function genererManquants(): array
|
||||
{
|
||||
$produits = [];
|
||||
|
||||
foreach ($this->manquants() as $identite) {
|
||||
$role = (string) $identite['role'];
|
||||
$agentId = $identite['id_agent'] !== null ? (int) $identite['id_agent'] : null;
|
||||
$structureId = $identite['id_structure'] !== null ? (int) $identite['id_structure'] : null;
|
||||
|
||||
$resultat = $this->creer($role, $agentId, $structureId, (string) $identite['libelle']);
|
||||
$produits[] = [
|
||||
'id_compte' => (int) ($resultat['compte']['id_compte'] ?? 0),
|
||||
'role' => $role,
|
||||
'libelle' => (string) ($resultat['compte']['identite_libelle'] ?? $identite['libelle']),
|
||||
'jeton' => $resultat['jeton'],
|
||||
];
|
||||
}
|
||||
|
||||
return $produits;
|
||||
}
|
||||
|
||||
/** Nombre de comptes DSI actifs, hors compte $exclu. */
|
||||
public function compterDsiActives(?int $exclu = null): int
|
||||
{
|
||||
// Zéro plutôt que NULL : PDO tourne sans émulation de requêtes préparées,
|
||||
// un même paramètre nommé ne peut donc pas être répété dans la requête,
|
||||
// et aucun compte ne porte l'identifiant 0.
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT COUNT(*) FROM v_acces_compte
|
||||
WHERE role = :role AND actif = 1 AND identite_active = 1
|
||||
AND id_compte <> :exclu'
|
||||
);
|
||||
$stmt->execute(['role' => Access::ROLE_DSI, 'exclu' => $exclu ?? 0]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai tant qu'un compte actif porte encore le jeton d'installation, dont
|
||||
* l'empreinte est écrite en clair dans la migration versionnée — donc
|
||||
* publique. L'interface DSI s'en sert pour alerter en rouge.
|
||||
*
|
||||
* La question est posée à la base plutôt qu'à la liste affichée : un filtre
|
||||
* de recherche ne doit pas faire disparaître l'alerte.
|
||||
*/
|
||||
public function installationEnCours(): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT 1 FROM compte_acces
|
||||
WHERE jeton_empreinte = :empreinte AND actif = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['empreinte' => self::EMPREINTE_INSTALLATION]);
|
||||
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce compte réclame-t-il un mot de passe en plus de son jeton ?
|
||||
*
|
||||
* La règle vit dans la vue (`mot_de_passe_requis`), pas ici : la liste des
|
||||
* rôles concernés se modifie en SQL, comme le reste des droits depuis la
|
||||
* V7.8.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte.
|
||||
*/
|
||||
public static function exigeMotDePasse(array $compte): bool
|
||||
{
|
||||
return (int) ($compte['mot_de_passe_requis'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le titulaire a-t-il déjà choisi son mot de passe ?
|
||||
* Faux à la création du compte comme après une réinitialisation par la DSI :
|
||||
* la connexion suivante impose alors d'en définir un.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte.
|
||||
*/
|
||||
public static function motDePasseDefini(array $compte): bool
|
||||
{
|
||||
return (int) ($compte['mot_de_passe_defini'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie le mot de passe présenté, et remet l'empreinte à niveau si PHP a
|
||||
* changé d'algorithme par défaut depuis sa création.
|
||||
*
|
||||
* @param array<string, mixed> $compte Ligne de v_acces_compte, avec son hachage.
|
||||
*/
|
||||
public function verifierMotDePasse(array $compte, string $motDePasse): bool
|
||||
{
|
||||
$hash = (string) ($compte['mot_de_passe_hash'] ?? '');
|
||||
if ($hash === '' || !password_verify($motDePasse, $hash)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
|
||||
$this->enregistrerHash((int) $compte['id_compte'], password_hash($motDePasse, PASSWORD_DEFAULT));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le mot de passe choisi par le titulaire.
|
||||
*
|
||||
* @throws DomainException Si la politique n'est pas respectée.
|
||||
*/
|
||||
public function definirMotDePasse(int $compteId, string $motDePasse, string $confirmation): void
|
||||
{
|
||||
if ($motDePasse !== $confirmation) {
|
||||
throw new DomainException('Les deux mots de passe saisis ne sont pas identiques.');
|
||||
}
|
||||
|
||||
$this->assurerPolitique($motDePasse);
|
||||
$this->enregistrerHash($compteId, password_hash($motDePasse, PASSWORD_DEFAULT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Efface le mot de passe d'un compte : la DSI ne le remplace pas, elle le
|
||||
* RETIRE. Le titulaire en choisira un nouveau à sa prochaine connexion, avec
|
||||
* son jeton — la DSI ne connaît donc jamais le mot de passe de personne.
|
||||
*
|
||||
* @throws DomainException Si le compte n'existe pas ou n'a pas de mot de passe.
|
||||
*/
|
||||
public function reinitialiserMotDePasse(int $compteId): array
|
||||
{
|
||||
$compte = $this->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
throw new DomainException('Compte introuvable.');
|
||||
}
|
||||
if (!$compte['mot_de_passe_requis']) {
|
||||
throw new DomainException('Ce type de compte n’utilise pas de mot de passe.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE compte_acces SET mot_de_passe_hash = NULL, date_mot_de_passe = NULL WHERE id_compte = :id'
|
||||
);
|
||||
$stmt->execute(['id' => $compteId]);
|
||||
|
||||
return $compte;
|
||||
}
|
||||
|
||||
/**
|
||||
* Politique de mot de passe : la longueur, et rien d'autre.
|
||||
*
|
||||
* @throws DomainException Si le mot de passe est trop court, trop long ou
|
||||
* trop proche du jeton.
|
||||
*/
|
||||
private function assurerPolitique(string $motDePasse): void
|
||||
{
|
||||
// Mesure en OCTETS et non en caractères : c'est la limite de bcrypt, et
|
||||
// un accent compte pour deux.
|
||||
$longueur = strlen($motDePasse);
|
||||
|
||||
if ($longueur < self::MOT_DE_PASSE_MINIMUM) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le mot de passe doit contenir au moins %d caractères.',
|
||||
self::MOT_DE_PASSE_MINIMUM
|
||||
));
|
||||
}
|
||||
if ($longueur > self::MOT_DE_PASSE_MAXIMUM) {
|
||||
throw new DomainException(sprintf(
|
||||
'Le mot de passe ne peut pas dépasser %d caractères.',
|
||||
self::MOT_DE_PASSE_MAXIMUM
|
||||
));
|
||||
}
|
||||
if (str_starts_with(self::normaliser($motDePasse), 'PTA-')) {
|
||||
// Recopier son jeton dans le champ mot de passe ramènerait les deux
|
||||
// facteurs à un seul secret.
|
||||
throw new DomainException('Le mot de passe ne peut pas être votre jeton d’accès.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Écrit une empreinte de mot de passe et horodate le changement. */
|
||||
private function enregistrerHash(int $compteId, string $hash): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE compte_acces
|
||||
SET mot_de_passe_hash = :hash, date_mot_de_passe = NOW()
|
||||
WHERE id_compte = :id'
|
||||
);
|
||||
$stmt->execute(['hash' => $hash, 'id' => $compteId]);
|
||||
}
|
||||
|
||||
/** Un jeton en clair, prêt à être transmis : « PTA-XXXXXX-XXXXXX-… ». */
|
||||
public static function genererJeton(): string
|
||||
{
|
||||
$groupes = [];
|
||||
for ($groupe = 0; $groupe < self::GROUPES; $groupe++) {
|
||||
$caracteres = '';
|
||||
for ($index = 0; $index < self::TAILLE_GROUPE; $index++) {
|
||||
// random_int() et non rand() : le tirage doit être imprévisible.
|
||||
$caracteres .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
|
||||
}
|
||||
$groupes[] = $caracteres;
|
||||
}
|
||||
|
||||
return 'PTA-' . implode('-', $groupes);
|
||||
}
|
||||
|
||||
/** Empreinte stockée en base. */
|
||||
public static function empreinte(string $jeton): string
|
||||
{
|
||||
return hash('sha256', self::normaliser($jeton));
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie un jeton saisi à la main : espaces, casse et espaces insécables
|
||||
* collés par un copier-coller depuis un courriel.
|
||||
*/
|
||||
public static function normaliser(string $jeton): string
|
||||
{
|
||||
$jeton = str_replace(["\u{00A0}", ' ', "\t", "\r", "\n"], '', $jeton);
|
||||
|
||||
return strtoupper(trim($jeton));
|
||||
}
|
||||
|
||||
/** Début du jeton, conservé en base pour l'identifier sans le révéler. */
|
||||
private static function prefixe(string $jeton): string
|
||||
{
|
||||
return mb_substr(self::normaliser($jeton), 0, 11);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en forme une ligne de v_acces_compte pour l'API : types corrects,
|
||||
* empreinte remplacée par le seul drapeau qui intéresse l'interface.
|
||||
*
|
||||
* @param array<string, mixed> $ligne
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function presenter(array $ligne): array
|
||||
{
|
||||
return [
|
||||
'id_compte' => (int) $ligne['id_compte'],
|
||||
'role' => (string) $ligne['role'],
|
||||
'libelle' => (string) $ligne['libelle'],
|
||||
'identite_libelle' => (string) ($ligne['identite_libelle'] ?? $ligne['libelle']),
|
||||
'identite_active' => (int) ($ligne['identite_active'] ?? 1) === 1,
|
||||
'id_agent' => $ligne['id_agent'] !== null ? (int) $ligne['id_agent'] : null,
|
||||
'id_structure' => $ligne['id_structure'] !== null ? (int) $ligne['id_structure'] : null,
|
||||
'matricule' => $ligne['matricule'] ?? null,
|
||||
'structure_nom' => $ligne['structure_nom'] ?? null,
|
||||
'jeton_prefixe' => (string) $ligne['jeton_prefixe'],
|
||||
// Le jeton d'installation est public : l'interface doit pouvoir
|
||||
// crier tant qu'il n'a pas été remplacé.
|
||||
'jeton_installation' => (string) $ligne['jeton_empreinte'] === self::EMPREINTE_INSTALLATION,
|
||||
'actif' => (int) $ligne['actif'] === 1,
|
||||
// Le hachage ne sort jamais du serveur : l'interface n'a besoin que
|
||||
// de savoir si un mot de passe est exigé et s'il a été choisi.
|
||||
'mot_de_passe_requis' => (int) ($ligne['mot_de_passe_requis'] ?? 0) === 1,
|
||||
'mot_de_passe_defini' => (int) ($ligne['mot_de_passe_defini'] ?? 0) === 1,
|
||||
'date_mot_de_passe' => $ligne['date_mot_de_passe'] ?? null,
|
||||
'commentaire' => $ligne['commentaire'] ?? null,
|
||||
'date_creation' => $ligne['date_creation'],
|
||||
'date_rotation' => $ligne['date_rotation'],
|
||||
'date_derniere_connexion' => $ligne['date_derniere_connexion'],
|
||||
'nombre_connexions' => (int) $ligne['nombre_connexions'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interdit de se couper soi-même la branche : le dernier accès DSI actif ne
|
||||
* peut être ni désactivé ni supprimé. Sans ce garde-fou, il faudrait rejouer
|
||||
* la migration pour récupérer la main.
|
||||
*
|
||||
* @param array<string, mixed> $compte
|
||||
*/
|
||||
private function assurerDerniereDsi(array $compte): void
|
||||
{
|
||||
if ((string) $compte['role'] !== Access::ROLE_DSI || !$compte['actif']) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->compterDsiActives((int) $compte['id_compte']) === 0) {
|
||||
throw new DomainException(
|
||||
'Ce compte est le dernier accès DSI actif : créez-en un autre avant de le retirer.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Libellé d'un agent actif, ou refus si l'agent n'existe pas ou est inactif. */
|
||||
private function libelleAgent(int $agentId): string
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT CONCAT(prenom, \' \', nom) FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $agentId]);
|
||||
$libelle = $stmt->fetchColumn();
|
||||
|
||||
if ($libelle === false) {
|
||||
throw new DomainException('Agent introuvable ou inactif.');
|
||||
}
|
||||
|
||||
return (string) $libelle;
|
||||
}
|
||||
|
||||
/** Libellé d'un lieu actif, ou refus si le lieu n'existe pas ou est fermé. */
|
||||
private function libelleStructure(int $structureId): string
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT nom FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $structureId]);
|
||||
$libelle = $stmt->fetchColumn();
|
||||
|
||||
if ($libelle === false) {
|
||||
throw new DomainException('Lieu d’affectation introuvable ou inactif.');
|
||||
}
|
||||
|
||||
return (string) $libelle;
|
||||
}
|
||||
}
|
||||
265
app/Models/JournalModel.php
Normal file
265
app/Models/JournalModel.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Lecture du journal d'activité pour la page DSI (V7.11).
|
||||
*
|
||||
* Ce modèle ne fait que LIRE — l'écriture appartient à App\Core\Journal, qui
|
||||
* n'est jamais loin d'une action métier. La seule exception est la purge, une
|
||||
* opération d'exploitation déclenchée à la main par la DSI.
|
||||
*
|
||||
* Le volume est la contrainte de conception : un journal se consulte par
|
||||
* tranches. Toutes les recherches sont donc paginées et bornées, et le compte
|
||||
* total est demandé séparément plutôt que ramené ligne par ligne.
|
||||
*/
|
||||
final class JournalModel extends BaseModel
|
||||
{
|
||||
/** Taille de page par défaut, alignée sur ce qu'un écran affiche sans lassitude. */
|
||||
public const TAILLE_PAGE = 50;
|
||||
|
||||
/** Plafond dur : aucune requête ne peut ramener davantage de lignes. */
|
||||
private const TAILLE_PAGE_MAX = 200;
|
||||
|
||||
/**
|
||||
* Recherche paginée dans le journal.
|
||||
*
|
||||
* @param array<string, mixed> $filtres categorie, resultat, role, id_compte,
|
||||
* q (texte libre), du, au (AAAA-MM-JJ),
|
||||
* page, taille.
|
||||
*
|
||||
* @return array{entrees: array<int, array<string, mixed>>, total: int, page: int, taille: int, pages: int}
|
||||
*/
|
||||
public function rechercher(array $filtres = []): array
|
||||
{
|
||||
[$where, $params] = $this->conditions($filtres);
|
||||
|
||||
$taille = (int) ($filtres['taille'] ?? self::TAILLE_PAGE);
|
||||
$taille = max(10, min(self::TAILLE_PAGE_MAX, $taille));
|
||||
$page = max(1, (int) ($filtres['page'] ?? 1));
|
||||
|
||||
$total = $this->compter($where, $params);
|
||||
$pages = $total === 0 ? 1 : (int) ceil($total / $taille);
|
||||
// Une page vidée par un filtre plus restrictif ne doit pas renvoyer un
|
||||
// écran blanc : on se replie sur la dernière page réellement peuplée.
|
||||
$page = min($page, $pages);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id_journal, date_evenement, categorie, action, resultat, id_compte, role,
|
||||
acteur, cible_table, cible_id, description, contexte, adresse_ip,
|
||||
agent_utilisateur, jeton_prefixe
|
||||
FROM v_journal_activite' . $where . '
|
||||
ORDER BY date_evenement DESC, id_journal DESC
|
||||
LIMIT :limite OFFSET :decalage'
|
||||
);
|
||||
|
||||
foreach ($params as $nom => $valeur) {
|
||||
$stmt->bindValue(':' . $nom, $valeur);
|
||||
}
|
||||
// LIMIT et OFFSET doivent être liés en ENTIER : sans émulation de
|
||||
// requêtes préparées, une valeur liée en chaîne produirait un LIMIT '50'
|
||||
// que MariaDB refuse.
|
||||
$stmt->bindValue(':limite', $taille, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':decalage', ($page - 1) * $taille, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return [
|
||||
'entrees' => array_map([$this, 'presenter'], $stmt->fetchAll()),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'taille' => $taille,
|
||||
'pages' => $pages,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Quelques chiffres de tête pour le bandeau de la page DSI.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function statistiques(): array
|
||||
{
|
||||
$total = (int) $this->pdo->query('SELECT COUNT(*) FROM journal_activite')->fetchColumn();
|
||||
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT categorie, COUNT(*) AS nombre
|
||||
FROM journal_activite
|
||||
WHERE date_evenement >= NOW() - INTERVAL 7 DAY
|
||||
GROUP BY categorie'
|
||||
);
|
||||
$parCategorie = [];
|
||||
foreach ($stmt->fetchAll() as $ligne) {
|
||||
$parCategorie[(string) $ligne['categorie']] = (int) $ligne['nombre'];
|
||||
}
|
||||
|
||||
$refus24h = (int) $this->pdo->query(
|
||||
'SELECT COUNT(*) FROM journal_activite
|
||||
WHERE resultat = \'REFUS\' AND date_evenement >= NOW() - INTERVAL 1 DAY'
|
||||
)->fetchColumn();
|
||||
|
||||
$connexions24h = (int) $this->pdo->query(
|
||||
'SELECT COUNT(*) FROM journal_activite
|
||||
WHERE categorie = \'CONNEXION\' AND resultat = \'SUCCES\'
|
||||
AND date_evenement >= NOW() - INTERVAL 1 DAY'
|
||||
)->fetchColumn();
|
||||
|
||||
$plusAncienne = $this->pdo->query(
|
||||
'SELECT MIN(date_evenement) FROM journal_activite'
|
||||
)->fetchColumn();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'sept_jours' => $parCategorie,
|
||||
'refus_24h' => $refus24h,
|
||||
'connexions_24h' => $connexions24h,
|
||||
'plus_ancienne' => $plusAncienne ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de secrets refusés pour une adresse IP sur les dernières minutes.
|
||||
*
|
||||
* C'est la mesure qui alimente le frein anti-force-brute de la page de
|
||||
* connexion : le journal sert ici de compteur, sans table supplémentaire.
|
||||
* Les deux facteurs comptent dans le même compteur — jeton refusé (V7.11) et
|
||||
* mot de passe refusé (V7.12) : essayer mille mots de passe derrière un jeton
|
||||
* volé doit buter sur le même mur qu'essayer mille jetons.
|
||||
*/
|
||||
public function echecsRecents(string $adresseIp, int $minutes): int
|
||||
{
|
||||
if ($adresseIp === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT COUNT(*) FROM journal_activite
|
||||
WHERE categorie = \'CONNEXION\'
|
||||
AND action IN (\'JETON_REFUSE\', \'MOT_DE_PASSE_REFUSE\')
|
||||
AND adresse_ip = :ip
|
||||
AND date_evenement >= NOW() - INTERVAL :minutes MINUTE'
|
||||
);
|
||||
$stmt->bindValue(':ip', $adresseIp);
|
||||
$stmt->bindValue(':minutes', $minutes, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge les entrées antérieures à $jours jours.
|
||||
*
|
||||
* Le plancher de 30 jours est volontaire : une purge trop courte reviendrait
|
||||
* à effacer les traces d'un incident avant même qu'il soit constaté.
|
||||
*
|
||||
* @return int Nombre de lignes supprimées.
|
||||
*/
|
||||
public function purger(int $jours): int
|
||||
{
|
||||
$jours = max(30, $jours);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'DELETE FROM journal_activite WHERE date_evenement < NOW() - INTERVAL :jours DAY'
|
||||
);
|
||||
$stmt->bindValue(':jours', $jours, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la clause WHERE commune à la recherche et au comptage.
|
||||
*
|
||||
* @param array<string, mixed> $filtres
|
||||
*
|
||||
* @return array{0: string, 1: array<string, mixed>}
|
||||
*/
|
||||
private function conditions(array $filtres): array
|
||||
{
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
|
||||
foreach (['categorie' => 'categorie', 'resultat' => 'resultat', 'role' => 'role'] as $filtre => $colonne) {
|
||||
$valeur = trim((string) ($filtres[$filtre] ?? ''));
|
||||
if ($valeur !== '') {
|
||||
$conditions[] = $colonne . ' = :' . $filtre;
|
||||
$params[$filtre] = $valeur;
|
||||
}
|
||||
}
|
||||
|
||||
$compteId = (int) ($filtres['id_compte'] ?? 0);
|
||||
if ($compteId > 0) {
|
||||
$conditions[] = 'id_compte = :id_compte';
|
||||
$params['id_compte'] = $compteId;
|
||||
}
|
||||
|
||||
$du = trim((string) ($filtres['du'] ?? ''));
|
||||
if ($du !== '') {
|
||||
$conditions[] = 'date_evenement >= :du';
|
||||
$params['du'] = $du . ' 00:00:00';
|
||||
}
|
||||
|
||||
$au = trim((string) ($filtres['au'] ?? ''));
|
||||
if ($au !== '') {
|
||||
$conditions[] = 'date_evenement <= :au';
|
||||
$params['au'] = $au . ' 23:59:59';
|
||||
}
|
||||
|
||||
$recherche = trim((string) ($filtres['q'] ?? ''));
|
||||
if ($recherche !== '') {
|
||||
$conditions[] = '(acteur LIKE :q OR action LIKE :q OR description LIKE :q OR adresse_ip LIKE :q)';
|
||||
$params['q'] = '%' . $recherche . '%';
|
||||
}
|
||||
|
||||
return [$conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions), $params];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private function compter(string $where, array $params): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM v_journal_activite' . $where);
|
||||
$stmt->execute($params);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en forme une entrée pour l'API. `contexte` est décodé ici : le client
|
||||
* reçoit un objet, pas une chaîne JSON à réanalyser.
|
||||
*
|
||||
* @param array<string, mixed> $ligne
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function presenter(array $ligne): array
|
||||
{
|
||||
$contexte = null;
|
||||
if (!empty($ligne['contexte'])) {
|
||||
$decode = json_decode((string) $ligne['contexte'], true);
|
||||
$contexte = is_array($decode) ? $decode : null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id_journal' => (int) $ligne['id_journal'],
|
||||
'date_evenement' => (string) $ligne['date_evenement'],
|
||||
'categorie' => (string) $ligne['categorie'],
|
||||
'action' => (string) $ligne['action'],
|
||||
'resultat' => (string) $ligne['resultat'],
|
||||
'id_compte' => $ligne['id_compte'] !== null ? (int) $ligne['id_compte'] : null,
|
||||
'role' => $ligne['role'],
|
||||
'acteur' => $ligne['acteur'],
|
||||
'cible_table' => $ligne['cible_table'],
|
||||
'cible_id' => $ligne['cible_id'] !== null ? (int) $ligne['cible_id'] : null,
|
||||
'description' => $ligne['description'],
|
||||
'contexte' => $contexte,
|
||||
'adresse_ip' => $ligne['adresse_ip'],
|
||||
'agent_utilisateur' => $ligne['agent_utilisateur'],
|
||||
'jeton_prefixe' => $ligne['jeton_prefixe'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -437,6 +437,34 @@ final class PlanningModel extends BaseModel
|
||||
*
|
||||
* @return array<int, array{c1: int, c2: int, date_jour: string}>
|
||||
*/
|
||||
/**
|
||||
* Créneaux du planning tombant hors des dates de contrat de leur agent.
|
||||
*
|
||||
* Contrôle de VALIDATION, complémentaire des gardes posées à l'écriture
|
||||
* (V7.15) : celles-ci empêchent d'en créer de nouveaux, celui-ci révèle ceux
|
||||
* qui existaient déjà — saisis avant la règle, ou rendus illégitimes par un
|
||||
* contrat raccourci depuis. La comparaison est faite en SQL, sur la fiche
|
||||
* agent jointe : c'est elle qui fait foi au moment du contrôle.
|
||||
*
|
||||
* @return array<int, array{id_creneau: int, date_jour: string}>
|
||||
*/
|
||||
public function entriesOutsideContract(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT c.id_creneau, c.date_jour
|
||||
FROM creneau_horaire c
|
||||
JOIN planning p ON p.id_planning = c.id_planning
|
||||
JOIN agent a ON a.id_agent = p.id_agent
|
||||
WHERE c.id_planning = :id_planning
|
||||
AND ((a.date_debut_contrat IS NOT NULL AND c.date_jour < a.date_debut_contrat)
|
||||
OR (a.date_fin_contrat IS NOT NULL AND c.date_jour > a.date_fin_contrat))
|
||||
ORDER BY c.date_jour'
|
||||
);
|
||||
$stmt->execute(['id_planning' => $planningId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function internalOverlaps(int $planningId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
|
||||
@@ -56,6 +56,8 @@ final class SchemaModel extends BaseModel
|
||||
'equipe_pta',
|
||||
'equipe_pta_membre',
|
||||
'structure_creneau_reference',
|
||||
'compte_acces',
|
||||
'journal_activite',
|
||||
];
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($requiredTables), '?'));
|
||||
@@ -82,6 +84,7 @@ final class SchemaModel extends BaseModel
|
||||
'planning' => ['date_validation'],
|
||||
'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'droit_enveloppe_formation', 'commentaire_pta'],
|
||||
'structure' => ['type_affectation'],
|
||||
'compte_acces' => ['mot_de_passe_hash'],
|
||||
];
|
||||
|
||||
foreach ($requiredColumns as $table => $columns) {
|
||||
@@ -108,6 +111,7 @@ final class SchemaModel extends BaseModel
|
||||
}
|
||||
|
||||
$this->assertAccessViews();
|
||||
$this->assertJetonViews();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,4 +149,39 @@ final class SchemaModel extends BaseModel
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vues des comptes à jeton et du journal (V7.11).
|
||||
*
|
||||
* Même logique fail-closed que pour les vues de droits : sans elles,
|
||||
* personne ne peut plus s'authentifier et aucune action ne serait tracée.
|
||||
* Mieux vaut un message explicite au démarrage qu'une application ouverte
|
||||
* sans journal.
|
||||
*/
|
||||
private function assertJetonViews(): void
|
||||
{
|
||||
$requiredViews = [
|
||||
'v_acces_compte',
|
||||
'v_acces_compte_manquant',
|
||||
'v_journal_activite',
|
||||
];
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($requiredViews), '?'));
|
||||
$stmt = $this->pdo->prepare(
|
||||
"SELECT TABLE_NAME
|
||||
FROM information_schema.VIEWS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute($requiredViews);
|
||||
$missing = array_values(array_diff($requiredViews, $stmt->fetchAll(\PDO::FETCH_COLUMN)));
|
||||
|
||||
if ($missing) {
|
||||
throw new \RuntimeException(
|
||||
'Comptes d’accès indisponibles : vue(s) manquante(s) : '
|
||||
. implode(', ', $missing)
|
||||
. '. Exécutez database/migration_v7_9e_dsi_comptes_journal.sql.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user