Compare commits

..

8 Commits

Author SHA1 Message Date
gitea-actions 45c32989e5 chore: bump version to v0.1.125
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 3m49s
2026-06-24 14:03:29 +00:00
tristan 77ae6820d7 feat(calendar) : supprimer / modifier une plage de congés d'un coup (#34)
Auto Tag Develop / tag (push) Successful in 12s
## Contexte

Sur le calendrier, une absence est stockée **une ligne par jour** sans lien entre les jours (cf. `AbsenceWriteProcessor::expandAbsenceRange`). Conséquences pour la RH :
- **Impossible de retirer une plage** : la suppression n'effaçait que le jour cliqué → il fallait supprimer chaque jour un par un.
- **Modifier une plage laissait des incohérences** : le `PATCH` réutilisait une ligne et en recréait d'autres sans nettoyer l'ancien bloc → jours « fantômes » au raccourcissement et doublons à l'allongement.

## Changements (`frontend/pages/calendar.vue`)

- **Supprimer** (`handleDelete`) : efface **toutes** les absences de l'employé comprises dans la plage `[début ; fin]` du drawer. Flux RH : clic sur un jour → étendre la date de fin → Supprimer. Jours sans absence ignorés (aucune erreur) ; jour validé (`isValid`/site) protégé côté backend. Confirmation avec nombre de jours + intervalle.
- **Modifier** (`handleSubmit`) : **remplacement de bloc** — supprime l'ancien bloc contigu de même type (vers l'avant depuis le jour cliqué) + les absences recouvertes par la nouvelle plage, puis recrée la plage via `createAbsence`. Corrige le bug du `PATCH`. Les jours antérieurs au jour cliqué ne sont jamais touchés ; confirmation « chevauche une autre » seulement pour un autre type. `updateAbsence` n'est plus appelé depuis le calendrier.

## Pourquoi côté frontend

Le backend ne peut pas reconstituer « la plage » (aucun identifiant de groupe en BDD) ; le frontend a la plage visible. Vérifié : les écrans **Heures** et **Heures Conducteurs** verrouillent les dates du drawer (`lock-dates`), donc le `PATCH` y reste mono-jour — le calendrier est le seul écran à reshaper une plage. `AbsenceWriteProcessor` non modifié.

## Documentation

- `doc/functional-rules.md`, `frontend/data/documentation-content.ts` (in-app), `CLAUDE.md`.

## Tests

- 253 tests PHPUnit verts (hook pre-commit). Pas de framework de test frontend dans le projet.
- À valider en réel : raccourcir / allonger une plage (pas de jour fantôme ni doublon), supprimer une plage d'un coup.

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

Reviewed-on: #34
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-24 14:03:16 +00:00
gitea-actions 0333270089 chore: bump version to v0.1.124
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 3m41s
2026-06-24 11:56:51 +00:00
tristan 832751d1ed feat(audit) : contexte forensique dans le journal d'activité (IP, appareil, device id) (#33)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte

Certains comptes sont **partagés** par plusieurs personnes (ex. compte « Usine »), y compris depuis des smartphones. Le journal d'activité ne stockait que le `username` → impossible de distinguer les intervenants. Cette PR ajoute un **contexte forensique automatique** à chaque entrée du journal.

## Ce qui est ajouté (capté automatiquement, sans friction utilisateur)

- **Adresse IP** de la requête
- **User-Agent brut** (borné à 1024 caractères)
- **Libellé appareil lisible** dérivé du User-Agent : `Type · OS · Navigateur` (ex. `Mobile · Android · Chrome`)
- **Identifiant d'appareil persistant** envoyé par le front (header `X-Device-Id`, stocké en `localStorage`, borné à 64 car.) — distingue les **appareils** derrière un compte partagé

## Implémentation

- `UserAgentParser` (service maison, sans dépendance) — détection ordonnée OS/navigateur, testée
- 4 colonnes **nullable** sur `audit_logs` + migration réversible (pas de backfill, rétro-compatible)
- Capture **centralisée** dans `AuditLogger::log()` via `RequestStack` — aucun processor modifié
- Champs exposés dans l'API lecture (`AuditLogProvider` + DTO TS aligné) via `AuditLogReadRepositoryInterface` (suit le pattern existant des autres read-repos)
- Front : `useDeviceId` + injection du header `X-Device-Id` dans `useApi` (sur toutes les requêtes, SSR-safe)
- `framework.trusted_proxies` documenté (commenté) pour une IP correcte derrière un reverse proxy
- Docs : `doc/audit-logging.md` + `CLAUDE.md`

## Hors périmètre (étapes suivantes)

- **Écran du journal (`audit-logs.vue`) non modifié** — l'affichage des nouvelles colonnes fera l'objet d'une refonte séparée. Les données sont prêtes côté API.
- La doc in-app (`documentation-content.ts`) n'est pas touchée : le journal est un outil caché `ROLE_SUPER_ADMIN` sans article existant ni niveau de doc super-admin.

## À noter pour le déploiement

- L'IP n'est fiable derrière un reverse proxy qu'une fois `framework.trusted_proxies` activé (livré commenté).

## Tests

`OK (249 tests, 533 assertions)` — sortie PHPUnit propre (aucune notice).

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

Reviewed-on: #33
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-24 11:56:42 +00:00
gitea-actions c119db0b02 chore: bump version to v0.1.123
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 46s
2026-06-24 07:44:43 +00:00
tristan a52c35e082 fix: Malio UI (#32)
Auto Tag Develop / tag (push) Successful in 12s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #32
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-24 07:44:30 +00:00
gitea-actions a1af125c78 chore: bump version to v0.1.122
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 2m6s
2026-06-24 07:16:54 +00:00
tristan 8e59e9fd6a fix(heures) : n'enregistrer que les lignes modifiées (anti-écrasement concurrent) (#31)
Auto Tag Develop / tag (push) Successful in 10s
## Problème

Sur l'écran **Heures** / **Heures Conducteurs**, l'enregistrement envoyait au bulk-upsert une entrée pour **tous** les employés visibles non verrouillés, à partir de l'état en mémoire de la grille. Le backend (`WorkHourBulkUpsertProcessor`) traitant une **entrée vide comme une suppression**, un admin avec une grille **périmée** pouvait supprimer une ligne saisie entre-temps par un autre utilisateur.

### Scénario reproduit
1. Un admin ouvre l'écran ; la ligne d'un salarié `ROLE_SELF` est vide.
2. Ce salarié saisit ses heures dans sa propre session → ligne créée, **non validée** (donc non verrouillée).
3. L'admin, sur sa grille périmée, enregistre d'autres employés.
4. Le payload contient une entrée **vide** pour le salarié → le backend supprime sa ligne. **Perte de données.**

## Correctif (suivi des lignes modifiées)

`hydrateRows` capture un instantané `loadedRows` de l'état chargé depuis le serveur. `handleSave` ne transmet plus que les lignes **dont l'état courant diffère de l'instantané**.

- Ligne **intouchée** → jamais envoyée → jamais supprimée 
- Ligne **vidée volontairement** → envoyée vide → supprimée (métier conservé)
- Ligne **remplie/modifiée** → envoyée → créée/mise à jour

Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`.

## Limite connue
Pas de verrou optimiste backend : l'édition **explicite** d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne (hors périmètre).

## Doc
- `doc/hours-save-dirty-tracking.md` (nouveau)
- Note `CLAUDE.md` (section *Validation Rules*)

## Vérification
- Pre-commit hook : **236 tests PHPUnit OK**.
- Pas de harnais de tests frontend (revue de code uniquement).

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

Reviewed-on: #31
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-24 07:16:42 +00:00
35 changed files with 3738 additions and 395 deletions
+7
View File
@@ -39,6 +39,10 @@
- **Calendrier des jours validés (vue Jour)** (`WorkHourValidationStatusProvider`, ressource `WorkHourValidationStatus`, endpoint `GET /work-hours/validation-status?from=&to=[&driver=1]`, `ROLE_USER`) : en **vue Jour**, sur les **deux écrans** (Heures **et** Heures Conducteurs), le sélecteur de date est un `MalioDate` (layer `@malio/layer-ui >= 1.7.x` : prop `markedDates` + event `@month-change`) qui peint **en vert** (`markedDates``'success'`) les jours **entièrement validés**. **Définition** : un jour est vert ssi il porte ≥1 ligne `WorkHour` du scope ce jour-là **et** aucune n'est `isValid=false` — on se base sur la **seule** colonne `is_valid` (validation admin ; `isSiteValid` ignoré). Jour **sans aucune ligne** → neutre (jamais vert). **Périmètre complet** via `EmployeeRepository::findScoped` (admin = tous sites, chef de site = ses sites), **indépendant du filtre sites** de l'écran. **Scope conducteur inversé** par `?driver=1` : écran Heures → non-conducteurs (défaut), écran Heures Conducteurs → conducteurs (résolu par date via `EmployeeContractResolver::resolveIsDriverForEmployeeAndDate`, mémoïsé ; garde `if ($isDriver !== $driverOnly) continue`). Provider : une requête `WorkHourReadRepositoryInterface::findByDateRangeAndEmployees`, agrégation par jour (`total`/`pending`), plage bornée à 366 j. **Chargement à la volée par mois** (jamais préchargé) : `@month-change {month,year}` (à l'ouverture + nav) → fetch de la **grille visible** (lundi avant le 1er → dimanche après le dernier) → cache `validatedDaysByMonth` (`useHoursPage` / `useDriverHoursPage`, ce dernier passe `{ driver: true }` au service) → `markedDates` réactif. **Rafraîchissement** du mois en cache (`reloadValidationMonth`) après `toggleValidation`/`toggleValidationBulk`/`handleSave`/`refreshAfterAbsenceChange` (pas la validation site). La **vue Semaine** utilise un `MalioDateWeek` (sélecteur de semaine, v-model ISO week `YYYY-Www`, sans coloration). Le **stepper du mode jour est remplacé** par le `MalioDate` (raccourcis Hier/Aujourd'hui/Demain conservés) ; `PeriodStepperPicker` reste un fallback de la vue Jour quand `showValidationCalendar` est absent (aucun appelant actuel). Activation par écran via la prop `showValidationCalendar` de `HoursToolbar` (les deux pages la passent à `true` + `markedDates` + `@month-change`). Alignement vertical de la ligne via `lg:items-center` (les champs Malio font `h-12` vs `h-10` des boutons). Doc complète : `doc/hours-validated-days.md`.
- **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (source unique mutualisée avec `WorkHourWeeklySummaryProvider`, `YearlyHoursExportBuilder`, `RttRecoveryComputationService` et `SalaryRecapPrintProvider`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`.
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. **Le récap salaire applique le même filtre** (`SalaryRecapPrintProvider::hasContractInRange` sur le mois imprimé) : un salarié sans contrat sur le mois (ex. parti en février) n'apparaît pas sur le récap de juin.
- **Suppression / modification d'une plage de congés** (`calendar.vue`) : une absence = **une ligne par jour** en BDD (aucun lien entre les jours, cf. `expandAbsenceRange`), donc tout se gère côté frontend qui a la plage visible.
- **Supprimer** (`handleDelete`) : efface **toutes les absences de l'employé dont le jour tombe dans `[form.startDate ; form.endDate]`** (filtrage sur `absences.value`, boucle `deleteAbsence`), pas seulement le jour cliqué. Flux RH : clic sur un jour → drawer (début = fin) → étendre la date de fin → Supprimer. Jours sans absence ignorés ; jour validé (`isValid`/site) bloque sa propre ligne (backend). Confirmation avec nombre de jours + intervalle (`formatYmdToFr`) dès > 1 jour.
- **Modifier** (`handleSubmit`, branche `editingAbsence`) : **remplacement de bloc** — supprime l'**ancien bloc contigu** (jours adjacents de **même type**, en partant du jour cliqué **vers l'avant** via `shiftYmd`) **+** toute absence recouverte par la nouvelle plage, puis **`createAbsence`** sur la nouvelle plage (plus de `PATCH`). Corrige le bug historique : raccourcir ne laisse plus de **jours fantômes**, ré-étendre ne crée plus de **doublons**. Jamais de modification des jours **antérieurs** au jour cliqué ; confirmation « chevauche une autre » seulement si on écrase un **autre type**. La branche **Création** garde sa détection de chevauchement demi-journée (`getSegmentsForDate`).
- Backend `AbsenceWriteProcessor` (PATCH) **non touché** : il reste mono-jour en pratique car les écrans Heures/Heures Conducteurs verrouillent les dates du drawer (`lock-dates`), seul le calendrier reshape une plage. `updateAbsence` n'est plus appelé depuis `calendar.vue` (import retiré).
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
@@ -64,6 +68,7 @@
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
- Any real modification resets both `isSiteValid=false` and `isValid=false`
- No-op saves preserve existing validations
- **Enregistrement = seules les lignes modifiées sont envoyées (anti-écrasement concurrent)** : l'écran Heures / Heures Conducteurs affiche toute la journée, et le bulk-upsert (`WorkHourBulkUpsertProcessor`) traite une **entrée vide comme une suppression**. Pour éviter qu'un admin avec une grille **périmée** ne supprime une ligne saisie entre-temps par un autre utilisateur (ex. `ROLE_SELF` non encore validé → non verrouillé), `handleSave` ne transmet **que les lignes dont l'état courant diffère de l'instantané chargé** (`loadedRows`, capturé dans `hydrateRows` ; comparaison `JSON.stringify(buildEntry(current)) !== buildEntry(original)`). Une ligne intouchée n'est jamais envoyée → jamais supprimée. Vidée volontairement → envoyée vide → supprimée (métier conservé). Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`. **Limite** : pas de verrou optimiste backend — l'édition explicite d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne. Doc : `doc/hours-save-dirty-tracking.md`.
## Overtime Rules
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
@@ -207,6 +212,8 @@
- All processors that modify entities impacting calculations (heures, absences, contrats, RTT) MUST inject `AuditLogger` and log create/update/delete actions
- `AuditLogger::log()` persists without flushing — the processor's `flush()` handles both the data change and the audit entry atomically
- Audit logs are accessible only via `ROLE_SUPER_ADMIN` (hidden role, added manually in DB)
- **Contexte forensique automatique** : chaque entrée capte aussi `ipAddress`, `userAgent` (brut), `deviceLabel` (libellé lisible via `App\Service\UserAgentParser`) et `deviceId` (header `X-Device-Id`, device id persistant `localStorage['sirh-device-id']` envoyé par le front depuis `useApi`/`useDeviceId`). Capture centralisée dans `AuditLogger::log()` via `RequestStack` (null en contexte CLI). But : distinguer les appareils derrière un compte partagé (ex. « Usine »). IP fiable derrière proxy → activer `framework.trusted_proxies`. **CORS** : `X-Device-Id` doit rester dans `nelmio_cors.allow_headers` (front/API cross-origin → préflight, sinon le navigateur bloque toutes les requêtes). Affichage écran (`audit-logs.vue`) non couvert (refonte séparée). Doc : `doc/audit-logging.md`.
- **Écran Journal refondu** (`frontend/pages/audit-logs.vue` + `useAuditLogsList`) : tableau en `MalioDataTable` (1er usage SIRH), **drawer de filtre** façon STARSEED (`MalioDrawer` + `MalioAccordion`, état brouillon/appliqué, badge compteur, Réinitialiser/Appliquer), **drawer de détail** au clic ligne. Filtres backend : `employee` (LIKE nom/prénom de l'employé affecté, via join), `username`/`ip`/`device` (LIKE insensible casse), `entityType[]`/`action[]` (IN), `perPage` (10/25/50/100, défaut 10). Filtres du drawer = champs texte (recherche libre), période en `MalioDateRange`, type/action en cases à cocher. Logique dans `useAuditLogsList` ; libellés FR en dur ; filtres hors URL. Provider/`AuditLogReadRepositoryInterface`/repository portent les nouveaux critères.
- Documentation: `doc/audit-logging.md`
## Backend Conventions
+8
View File
@@ -5,6 +5,14 @@ framework:
# Note that the session will be started ONLY if you read or write from it.
session: true
# Trusted proxies — REQUIRED for a correct client IP in the activity log
# when SIRH runs behind a reverse proxy (nginx / traefik / cloud LB).
# Without this, Request::getClientIp() returns the PROXY ip, not the client's.
# Uncomment and set to the proxy network/CIDR of your deployment, e.g.:
# trusted_proxies: '127.0.0.1,REMOTE_ADDR,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16'
# trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
# trusted_proxies: '%env(TRUSTED_PROXIES)%'
#esi: true
#fragments: true
+1 -1
View File
@@ -3,7 +3,7 @@ nelmio_cors:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
allow_headers: ['Content-Type', 'Authorization', 'X-Device-Id']
allow_credentials: true
expose_headers: ['Link', 'Content-Disposition']
max_age: 3600
+1
View File
@@ -44,6 +44,7 @@ services:
$dataStartDate: '%env(RTT_START_DATE)%'
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
App\Repository\Contract\AuditLogReadRepositoryInterface: '@App\Repository\AuditLogRepository'
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
App\Repository\Contract\FormationReadRepositoryInterface: '@App\Repository\FormationRepository'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.121'
app.version: '0.1.125'
+17 -5
View File
@@ -40,16 +40,28 @@ Chaque entrée contient :
- **changes** : diff JSON `{old: {...}, new: {...}}` avec les anciennes/nouvelles valeurs
- **affectedDate** : date de travail ou début d'absence (pour filtrage par période)
- **createdAt** : horodatage de l'action
- `ipAddress` : IP source de la requête (`Request::getClientIp()`) — nécessite `framework.trusted_proxies` derrière un reverse proxy, sinon IP du proxy
- `userAgent` : User-Agent brut de la requête
- `deviceLabel` : libellé lisible dérivé du User-Agent (`Type · OS · Navigateur`, ex. `Mobile · Android · Chrome`), via `App\Service\UserAgentParser`
- `deviceId` : identifiant d'appareil persistant envoyé par le front (header `X-Device-Id`, stocké en `localStorage['sirh-device-id']`). Distingue les **appareils** derrière un compte partagé (ex. « Usine »), pas les personnes.
Capture : automatique et centralisée dans `AuditLogger::log()` (via `RequestStack`) — aucun processor à modifier. En contexte CLI/cron (pas de requête), ces 4 champs restent `null`.
> ⚠️ **CORS** : le front et l'API sont sur des origines distinctes ; le header `X-Device-Id` ajouté à chaque requête déclenche un préflight CORS. Il **doit** figurer dans `nelmio_cors.allow_headers` (`config/packages/nelmio_cors.yaml`), sinon le navigateur bloque toutes les requêtes API.
## Filtres disponibles
- Par employé
- Par plage de dates (date affectée)
- Par type d'entité
- Par employé (affecté) — champ texte, recherche partielle sur nom/prénom (insensible à la casse)
- Par période (date affectée) — sélecteur de plage
- Par type(s) d'entité (multi-sélection)
- Par action(s) (multi-sélection)
- Par utilisateur / compte — champ texte, recherche partielle (insensible à la casse)
- Par IP (recherche partielle)
- Par appareil (recherche partielle sur le libellé ou le device id)
## Pagination
Pagination : `perPage` (10 / 25 / 50 / 100, défaut 10) + `page`.
Les résultats sont paginés par 50 entrées. L'API retourne `{items, total, page, perPage}` et accepte un query param `page`.
L'écran utilise un `MalioDataTable`, un **drawer de filtre** (bouton « Filtrer » avec compteur de filtres actifs, état brouillon/appliqué, Réinitialiser/Appliquer) et un **drawer de détail** ouvert au clic sur une ligne (méta + contexte technique IP/appareil/User-Agent/device id + diff lisible des changements).
## Convention
+11
View File
@@ -78,6 +78,17 @@ Documents complementaires:
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
- demi-journée: dégradé diagonal
- journée complète: fond plein
- Suppression d'une plage depuis le Calendrier:
- clic sur un jour d'une plage → le drawer s'ouvre sur ce jour (début = fin = jour cliqué)
- on étend la **date de fin** (ou de début) pour couvrir la plage à effacer, puis bouton **Supprimer**
- **toutes** les absences de l'employé dont le jour tombe dans la plage sélectionnée sont supprimées (1 ligne/jour en BDD)
- les jours de la plage sans absence sont ignorés (aucune erreur) ; un jour validé (`isValid`/site) bloque sa propre suppression
- confirmation unique avant suppression ; au-delà de 1 jour le message rappelle le nombre de jours et l'intervalle
- Modification d'une plage depuis le Calendrier (bouton **Modifier**):
- une absence n'a **aucun lien** entre ses jours en BDD (1 ligne/jour). Modifier réalise donc un **remplacement de bloc** : on supprime l'ancien **bloc contigu** (jours adjacents de **même type**, en partant du jour cliqué **vers l'avant**) puis on **recrée** la nouvelle plage
- corrige le bug historique du PATCH : raccourcir une plage ne laisse plus de **jours fantômes** au-delà de la nouvelle fin, et ré-étendre ne crée plus de **doublons**
- les jours **antérieurs** au jour cliqué ne sont jamais touchés ; toute absence d'un autre type recouverte par la nouvelle plage déclenche une confirmation « chevauche une autre »
- implémenté côté frontend (`calendar.vue::handleSubmit`) car le backend ne peut pas reconstituer le bloc sans identifiant de groupe ; sans danger sur les écrans Heures/Heures Conducteurs où les dates du drawer sont verrouillées (`lock-dates`), donc le PATCH y reste mono-jour
- Visibilité des employés dans le Calendrier:
- un employé est affiché si au moins une de ses périodes de contrat intersecte le mois affiché
- un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué
+54
View File
@@ -0,0 +1,54 @@
# Enregistrement des heures — envoi des seules lignes modifiées
## Problème corrigé (perte de données par écrasement « à l'aveugle »)
L'écran **Heures** (et **Heures Conducteurs**) présente une grille d'une journée avec
**tous** les employés du périmètre. L'enregistrement (`POST /work-hours/bulk-upsert`,
`WorkHourBulkUpsertProcessor`) a une sémantique **upsert par (employé, date)** où une
**entrée vide supprime** la ligne existante (« une ligne vide supprime l'enregistrement »).
Avant correctif, `handleSave` (front) envoyait une entrée pour **chaque** employé visible non
verrouillé, à partir de l'état en mémoire de la grille. Conséquence en cas de **concurrence** :
1. Un admin ouvre l'écran ; la ligne d'un salarié (ex. utilisateur `ROLE_SELF`) est vide.
2. Ce salarié saisit ses heures dans sa propre session → ligne créée en BDD, **non validée**
(donc non verrouillée).
3. L'admin, sur sa grille **périmée**, saisit les heures d'**autres** employés et enregistre.
4. Le payload contient une entrée **vide** pour le salarié (état périmé). Le backend relit la
BDD (ligne désormais remplie), constate « entrée vide ≠ existant » → **supprime** la ligne
fraîchement saisie. Perte de données.
## Correctif (suivi des lignes modifiées côté front)
`hydrateRows` capture désormais un **instantané** des lignes telles que chargées depuis le
serveur (`loadedRows`, clone indépendant de `rows`). À l'enregistrement, `handleSave` ne
transmet **que les lignes dont l'état courant diffère de l'instantané chargé** :
```ts
const entries = candidates
.map((employee) => ({
current: buildEntry(employee, rows.value[employee.id] ?? emptyRow()),
original: buildEntry(employee, loadedRows.value[employee.id] ?? emptyRow()),
}))
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
.map(({ current }) => current)
```
Conséquences :
- Une ligne **intouchée** n'est jamais transmise → jamais supprimée, même si un autre
utilisateur l'a saisie/modifiée entre-temps. **C'est le correctif du bug.**
- Une ligne **vidée volontairement** par l'utilisateur diffère de l'instantané → transmise
vide → supprimée (comportement métier conservé).
- Une ligne **remplie** diffère → transmise → créée/mise à jour.
Implémenté symétriquement dans `frontend/composables/useHoursPage.ts` (non-conducteurs) et
`frontend/composables/useDriverHoursPage.ts` (conducteurs).
## Limite connue (hors périmètre de ce correctif)
Le suivi des lignes modifiées **ne couvre pas** le cas où l'admin **édite explicitement** une
ligne sur des données périmées (il voit la ligne vide, tape une valeur, écrasant une saisie
concurrente sur cette même ligne). Ce cas résiduel relèverait d'un **verrou optimiste**
(comparaison d'`updatedAt`/version côté backend), non implémenté ici. Le backend n'a aucune
détection de conflit concurrent (pas de version, pas d'horodatage comparé).
@@ -0,0 +1,904 @@
# Contexte forensique dans le journal d'activité — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Capter automatiquement IP, libellé appareil, User-Agent brut et identifiant d'appareil persistant sur chaque entrée du journal d'activité, et les exposer en API lecture, pour différencier les intervenants derrière un compte partagé (ex. « Usine »).
**Architecture:** Point de capture unique côté back (`AuditLogger::log()` + `RequestStack`) → aucun processor modifié. 4 colonnes nullable ajoutées à `audit_logs`. Un service `UserAgentParser` dérive un libellé lisible. Côté front, un device ID persistant (`localStorage`) est envoyé en header `X-Device-Id` sur toutes les requêtes API.
**Tech Stack:** Symfony 7 + API Platform + Doctrine (PostgreSQL) ; Nuxt 4 + Vue 3 + TypeScript (ofetch).
## Global Constraints
- Backend = source de vérité ; le front ne fait qu'envoyer le header et afficher. (CLAUDE.md)
- Toute écriture d'audit DOIT passer par `AuditLogger` — ne pas dupliquer la capture ailleurs. (CLAUDE.md, doc/audit-logging.md)
- Migrations Doctrine : toujours un `down()` fonctionnel. PostgreSQL. (CLAUDE.md)
- DTO PHP ↔ DTO TS alignés. (CLAUDE.md)
- Tout changement fonctionnel met à jour `doc/` + `frontend/data/documentation-content.ts` + `CLAUDE.md` dans la même intervention. (CLAUDE.md — règles obligatoires)
- **Ne PAS lancer `npm run build`** sauf demande explicite. (mémoire feedback utilisateur)
- Code (variables, commentaires) en anglais ; UI/libellés en français.
- Format de message de commit imposé par le hook : `<type> : <message>` (espace AVANT le `:`). Types : build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.
- Lancer un test ciblé : `make test FILES=<chemin>`. Conteneur PHP : `php-sirh-fpm`. Le pre-commit hook lance déjà tout PHPUnit + php-cs-fixer.
- **Hors périmètre (étape suivante, ne PAS toucher) :** l'écran `frontend/pages/audit-logs.vue` (affichage des nouvelles colonnes, filtre par appareil). On se contente d'exposer les champs dans l'API.
---
## File Structure
**Backend**
- `src/Service/UserAgentParser.php`*créer*. Parse un User-Agent en libellé court `Type · OS · Navigateur`.
- `tests/Service/UserAgentParserTest.php`*créer*.
- `src/Entity/AuditLog.php`*modifier*. +4 propriétés + accesseurs.
- `migrations/Version20260624120000.php`*créer*. +4 colonnes nullable sur `audit_logs`.
- `src/Service/AuditLogger.php`*modifier*. Injecte `RequestStack` + `UserAgentParser`, peuple les 4 champs.
- `tests/Service/AuditLoggerTest.php`*créer*.
- `src/State/AuditLogProvider.php`*modifier*. Expose les 4 champs dans le JSON.
- `tests/State/AuditLogProviderTest.php`*créer*.
- `config/packages/framework.yaml`*modifier*. Bloc `trusted_proxies` documenté (commenté).
**Frontend**
- `frontend/composables/useDeviceId.ts`*créer*. Device ID persistant.
- `frontend/composables/useApi.ts`*modifier*. Injecte le header `X-Device-Id` (intercepteur `onRequest`).
- `frontend/services/dto/audit-log.ts`*modifier*. +4 champs optionnels.
**Docs**
- `doc/audit-logging.md`, `frontend/data/documentation-content.ts`, `CLAUDE.md`*modifier*.
---
## Task 1: Service `UserAgentParser`
**Files:**
- Create: `src/Service/UserAgentParser.php`
- Test: `tests/Service/UserAgentParserTest.php`
**Interfaces:**
- Consumes: rien.
- Produces: `UserAgentParser::parse(?string $userAgent): ?string` → libellé `Type · OS · Navigateur` (ex. `Mobile · Android · Chrome`), ou `null` si UA vide/null. Consommé par Task 3.
- [ ] **Step 1: Write the failing test**
Create `tests/Service/UserAgentParserTest.php`:
```php
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\UserAgentParser;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class UserAgentParserTest extends TestCase
{
private UserAgentParser $parser;
protected function setUp(): void
{
$this->parser = new UserAgentParser();
}
public function testNullAndEmptyReturnNull(): void
{
self::assertNull($this->parser->parse(null));
self::assertNull($this->parser->parse(''));
self::assertNull($this->parser->parse(' '));
}
public function testChromeOnWindows(): void
{
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
self::assertSame('Ordinateur · Windows · Chrome', $this->parser->parse($ua));
}
public function testEdgeBeatsChrome(): void
{
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0';
self::assertSame('Ordinateur · Windows · Edge', $this->parser->parse($ua));
}
public function testSafariOnIphoneIsMobileIos(): void
{
$ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
self::assertSame('Mobile · iOS · Safari', $this->parser->parse($ua));
}
public function testChromeOnAndroid(): void
{
$ua = 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
self::assertSame('Mobile · Android · Chrome', $this->parser->parse($ua));
}
public function testFirefoxOnLinux(): void
{
$ua = 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0';
self::assertSame('Ordinateur · Linux · Firefox', $this->parser->parse($ua));
}
public function testSafariOnMac(): void
{
$ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
self::assertSame('Ordinateur · macOS · Safari', $this->parser->parse($ua));
}
public function testIpadIsTablet(): void
{
$ua = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
self::assertSame('Tablette · iOS · Safari', $this->parser->parse($ua));
}
public function testUnknownUaFallsBack(): void
{
self::assertSame('Ordinateur · Autre · Autre', $this->parser->parse('SomeRandomBot/1.0'));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `make test FILES=tests/Service/UserAgentParserTest.php`
Expected: FAIL — `Class "App\Service\UserAgentParser" not found`.
- [ ] **Step 3: Write minimal implementation**
Create `src/Service/UserAgentParser.php`:
```php
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Derives a short, human-readable label ("Type · OS · Browser") from a raw
* User-Agent string, used to add forensic context to audit log entries.
* Heuristic on purpose — enough to tell a phone from a desktop and identify
* OS/browser families on shared accounts.
*/
class UserAgentParser
{
public function parse(?string $userAgent): ?string
{
if (null === $userAgent) {
return null;
}
$ua = trim($userAgent);
if ('' === $ua) {
return null;
}
return implode(' · ', [
$this->detectType($ua),
$this->detectOs($ua),
$this->detectBrowser($ua),
]);
}
private function detectType(string $ua): string
{
if (1 === preg_match('/iPad|Tablet/i', $ua)) {
return 'Tablette';
}
if (1 === preg_match('/Mobile|Android|iPhone|iPod/i', $ua)) {
return 'Mobile';
}
return 'Ordinateur';
}
private function detectOs(string $ua): string
{
// Order matters: iOS before macOS (iOS UAs contain "Mac OS X"),
// Android before Linux (Android UAs contain "Linux").
return match (true) {
1 === preg_match('/iPhone|iPad|iPod/i', $ua) => 'iOS',
1 === preg_match('/Android/i', $ua) => 'Android',
1 === preg_match('/Windows/i', $ua) => 'Windows',
1 === preg_match('/Mac OS X|Macintosh/i', $ua) => 'macOS',
1 === preg_match('/Linux/i', $ua) => 'Linux',
default => 'Autre',
};
}
private function detectBrowser(string $ua): string
{
// Order matters: Edge/Opera contain "Chrome" and "Safari";
// Chrome contains "Safari". Match the most specific first.
return match (true) {
1 === preg_match('/Edg/i', $ua) => 'Edge',
1 === preg_match('/OPR|Opera/i', $ua) => 'Opera',
1 === preg_match('/Firefox|FxiOS/i', $ua) => 'Firefox',
1 === preg_match('/Chrome|CriOS/i', $ua) => 'Chrome',
1 === preg_match('/Safari/i', $ua) => 'Safari',
default => 'Autre',
};
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `make test FILES=tests/Service/UserAgentParserTest.php`
Expected: PASS (8 tests).
- [ ] **Step 5: Commit**
```bash
git add src/Service/UserAgentParser.php tests/Service/UserAgentParserTest.php
git commit -m "feat(audit) : ajoute UserAgentParser (libellé appareil lisible)"
```
---
## Task 2: Colonnes `audit_logs` + entité
**Files:**
- Modify: `src/Entity/AuditLog.php`
- Create: `migrations/Version20260624120000.php`
**Interfaces:**
- Produces (sur `AuditLog`) : `getIpAddress(): ?string` / `setIpAddress(?string): self` ; `getUserAgent(): ?string` / `setUserAgent(?string): self` ; `getDeviceLabel(): ?string` / `setDeviceLabel(?string): self` ; `getDeviceId(): ?string` / `setDeviceId(?string): self`. Consommés par Task 3 et Task 4.
- [ ] **Step 1: Add the 4 mapped properties to the entity**
In `src/Entity/AuditLog.php`, after the `affectedDate` property block (currently ends line 47, before `createdAt` declared line 4950), insert:
```php
#[ORM\Column(type: 'string', length: 45, nullable: true)]
private ?string $ipAddress = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $userAgent = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $deviceLabel = null;
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $deviceId = null;
```
- [ ] **Step 2: Add the accessors**
In `src/Entity/AuditLog.php`, after `setAffectedDate()` (ends line 156) and before `getCreatedAt()` (line 158), insert:
```php
public function getIpAddress(): ?string
{
return $this->ipAddress;
}
public function setIpAddress(?string $ipAddress): self
{
$this->ipAddress = $ipAddress;
return $this;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function setUserAgent(?string $userAgent): self
{
$this->userAgent = $userAgent;
return $this;
}
public function getDeviceLabel(): ?string
{
return $this->deviceLabel;
}
public function setDeviceLabel(?string $deviceLabel): self
{
$this->deviceLabel = $deviceLabel;
return $this;
}
public function getDeviceId(): ?string
{
return $this->deviceId;
}
public function setDeviceId(?string $deviceId): self
{
$this->deviceId = $deviceId;
return $this;
}
```
- [ ] **Step 3: Create the migration**
Create `migrations/Version20260624120000.php`:
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260624120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add forensic context columns (ip, user agent, device label, device id) to audit_logs';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE audit_logs ADD ip_address VARCHAR(45) DEFAULT NULL');
$this->addSql('ALTER TABLE audit_logs ADD user_agent TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE audit_logs ADD device_label VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE audit_logs ADD device_id VARCHAR(64) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE audit_logs DROP COLUMN ip_address');
$this->addSql('ALTER TABLE audit_logs DROP COLUMN user_agent');
$this->addSql('ALTER TABLE audit_logs DROP COLUMN device_label');
$this->addSql('ALTER TABLE audit_logs DROP COLUMN device_id');
}
}
```
- [ ] **Step 4: Apply the migration and verify the mapping**
Run: `make migration-migrate`
Expected: migration `Version20260624120000` applied, no error.
Then verify the Doctrine mapping matches the DB:
Run: `docker exec -t -u www-data php-sirh-fpm php bin/console doctrine:schema:validate`
Expected: `[OK] The mapping files are correct.` (the "database is in sync" line must also be OK for `audit_logs`).
- [ ] **Step 5: Commit**
```bash
git add src/Entity/AuditLog.php migrations/Version20260624120000.php
git commit -m "feat(audit) : colonnes contexte forensique sur audit_logs"
```
---
## Task 3: Capture du contexte dans `AuditLogger`
**Files:**
- Modify: `src/Service/AuditLogger.php`
- Create: `tests/Service/AuditLoggerTest.php`
**Interfaces:**
- Consumes: `UserAgentParser::parse()` (Task 1) ; setters de `AuditLog` (Task 2) ; `Symfony\Component\HttpFoundation\RequestStack`.
- Produces: signature publique de `AuditLogger::log()` **inchangée** (la capture est interne, automatique). Le constructeur gagne 2 dépendances autowirées.
- [ ] **Step 1: Write the failing test**
Create `tests/Service/AuditLoggerTest.php`:
```php
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\AuditLog;
use App\Service\AuditLogger;
use App\Service\UserAgentParser;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* @internal
*/
final class AuditLoggerTest extends TestCase
{
public function testCapturesRequestContext(): void
{
$persisted = null;
$em = $this->createMock(EntityManagerInterface::class);
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
$persisted = $entity;
});
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn(null); // -> username "system"
$request = Request::create('/api/work_hours', 'POST');
$request->server->set('REMOTE_ADDR', '203.0.113.7');
$request->headers->set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
$request->headers->set('X-Device-Id', 'device-abc');
$stack = new RequestStack();
$stack->push($request);
$logger = new AuditLogger($em, $security, $stack, new UserAgentParser());
$logger->log(null, 'create', 'work_hour', 1, 'desc');
self::assertInstanceOf(AuditLog::class, $persisted);
self::assertSame('203.0.113.7', $persisted->getIpAddress());
self::assertSame('device-abc', $persisted->getDeviceId());
self::assertSame('Ordinateur · Windows · Chrome', $persisted->getDeviceLabel());
self::assertNotNull($persisted->getUserAgent());
}
public function testTruncatesOverlongDeviceId(): void
{
$persisted = null;
$em = $this->createMock(EntityManagerInterface::class);
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
$persisted = $entity;
});
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn(null);
$request = Request::create('/api/work_hours', 'POST');
$request->headers->set('X-Device-Id', str_repeat('x', 200));
$stack = new RequestStack();
$stack->push($request);
$logger = new AuditLogger($em, $security, $stack, new UserAgentParser());
$logger->log(null, 'create', 'work_hour', 1, 'desc');
self::assertSame(64, mb_strlen((string) $persisted->getDeviceId()));
}
public function testNoRequestLeavesContextNull(): void
{
$persisted = null;
$em = $this->createMock(EntityManagerInterface::class);
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
$persisted = $entity;
});
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn(null);
$logger = new AuditLogger($em, $security, new RequestStack(), new UserAgentParser());
$logger->log(null, 'create', 'work_hour', 1, 'desc');
self::assertNull($persisted->getIpAddress());
self::assertNull($persisted->getUserAgent());
self::assertNull($persisted->getDeviceLabel());
self::assertNull($persisted->getDeviceId());
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `make test FILES=tests/Service/AuditLoggerTest.php`
Expected: FAIL — `AuditLogger::__construct()` expects 2 args (too few given), or `getIpAddress()` undefined if run before Task 2.
- [ ] **Step 3: Update the service**
Replace the full contents of `src/Service/AuditLogger.php` with:
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\AuditLog;
use App\Entity\Employee;
use App\Entity\User;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
readonly class AuditLogger
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private RequestStack $requestStack,
private UserAgentParser $userAgentParser,
) {}
public function log(
?Employee $employee,
string $action,
string $entityType,
?int $entityId,
string $description,
?array $changes = null,
?DateTimeImmutable $affectedDate = null,
): void {
$user = $this->security->getUser();
$username = $user instanceof User ? $user->getUsername() : 'system';
$request = $this->requestStack->getCurrentRequest();
$ipAddress = null;
$userAgent = null;
$deviceId = null;
if (null !== $request) {
$ipAddress = $request->getClientIp();
$userAgent = $request->headers->get('User-Agent');
$deviceId = $request->headers->get('X-Device-Id');
// The device id comes from an untrusted client header; cap it to the column width.
if (null !== $deviceId) {
$deviceId = mb_substr($deviceId, 0, 64);
}
}
$auditLog = new AuditLog();
$auditLog
->setEmployee($employee)
->setUsername($username)
->setAction($action)
->setEntityType($entityType)
->setEntityId($entityId)
->setDescription($description)
->setChanges($changes)
->setAffectedDate($affectedDate)
->setIpAddress($ipAddress)
->setUserAgent($userAgent)
->setDeviceLabel($this->userAgentParser->parse($userAgent))
->setDeviceId($deviceId)
;
$this->entityManager->persist($auditLog);
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `make test FILES=tests/Service/AuditLoggerTest.php`
Expected: PASS (3 tests).
- [ ] **Step 5: Run the full backend suite (no regression)**
Run: `make test`
Expected: OK — all tests green (existing processors that use `AuditLogger` are autowired, so the 2 new constructor args resolve automatically).
- [ ] **Step 6: Commit**
```bash
git add src/Service/AuditLogger.php tests/Service/AuditLoggerTest.php
git commit -m "feat(audit) : capture IP/appareil/user-agent dans AuditLogger"
```
---
## Task 4: Exposition des champs dans l'API lecture
**Files:**
- Modify: `src/State/AuditLogProvider.php:53-64` (le tableau `$items[]`)
- Modify: `frontend/services/dto/audit-log.ts`
- Create: `tests/State/AuditLogProviderTest.php`
**Interfaces:**
- Consumes: getters de `AuditLog` (Task 2).
- Produces: chaque item JSON du endpoint `GET /audit-logs` porte désormais `ipAddress`, `userAgent`, `deviceLabel`, `deviceId` (string|null). Le DTO TS `AuditLog` gagne ces 4 champs optionnels.
- [ ] **Step 1: Write the failing test**
Create `tests/State/AuditLogProviderTest.php`:
```php
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Operation;
use App\Entity\AuditLog;
use App\Repository\AuditLogRepository;
use App\State\AuditLogProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* @internal
*/
final class AuditLogProviderTest extends TestCase
{
public function testProvideExposesForensicFields(): void
{
$log = (new AuditLog())
->setUsername('usine')
->setAction('create')
->setEntityType('work_hour')
->setDescription('desc')
->setIpAddress('203.0.113.7')
->setUserAgent('UA-string')
->setDeviceLabel('Mobile · Android · Chrome')
->setDeviceId('device-abc')
;
$repo = $this->createMock(AuditLogRepository::class);
$repo->method('countByFilters')->willReturn(1);
$repo->method('findByFilters')->willReturn([$log]);
$stack = new RequestStack();
$stack->push(Request::create('/api/audit-logs', 'GET'));
$provider = new AuditLogProvider($stack, $repo);
$response = $provider->provide($this->createMock(Operation::class));
$data = json_decode((string) $response->getContent(), true);
$item = $data['items'][0];
self::assertSame('203.0.113.7', $item['ipAddress']);
self::assertSame('UA-string', $item['userAgent']);
self::assertSame('Mobile · Android · Chrome', $item['deviceLabel']);
self::assertSame('device-abc', $item['deviceId']);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `make test FILES=tests/State/AuditLogProviderTest.php`
Expected: FAIL — `Undefined array key "ipAddress"`.
- [ ] **Step 3: Add the fields to the provider output**
In `src/State/AuditLogProvider.php`, in the `$items[] = [ ... ]` block, add the 4 keys after `'affectedDate' => ...` and before `'createdAt' => ...`:
```php
'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
'ipAddress' => $log->getIpAddress(),
'userAgent' => $log->getUserAgent(),
'deviceLabel' => $log->getDeviceLabel(),
'deviceId' => $log->getDeviceId(),
'createdAt' => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'),
```
- [ ] **Step 4: Run test to verify it passes**
Run: `make test FILES=tests/State/AuditLogProviderTest.php`
Expected: PASS.
- [ ] **Step 5: Align the frontend DTO**
Replace the full contents of `frontend/services/dto/audit-log.ts` with:
```ts
export type AuditLog = {
id: number
employeeName: string | null
employeeId: number | null
username: string
action: string
entityType: string
description: string
changes: { old?: Record<string, unknown>; new?: Record<string, unknown> } | null
affectedDate: string | null
ipAddress: string | null
userAgent: string | null
deviceLabel: string | null
deviceId: string | null
createdAt: string
}
```
- [ ] **Step 6: Commit**
```bash
git add src/State/AuditLogProvider.php tests/State/AuditLogProviderTest.php frontend/services/dto/audit-log.ts
git commit -m "feat(audit) : expose le contexte forensique dans l'API lecture"
```
---
## Task 5: Device ID persistant côté front
**Files:**
- Create: `frontend/composables/useDeviceId.ts`
- Modify: `frontend/composables/useApi.ts` (intercepteur `onRequest` dans `$fetch.create`, lignes 79-170)
**Interfaces:**
- Consumes: rien.
- Produces: `useDeviceId(): string | null` (auto-importé Nuxt). Renvoie l'UUID stocké dans `localStorage['sirh-device-id']` (créé si absent), ou `null` côté serveur (SSR). `useApi` ajoute le header `X-Device-Id` sur toutes les requêtes API quand l'ID est disponible.
- [ ] **Step 1: Create the composable**
Create `frontend/composables/useDeviceId.ts`:
```ts
// Stable per-device identifier used to add forensic context to audit logs.
// Persisted in localStorage so the same browser/device reuses it across sessions.
// NOTE: this identifies a device/browser, not a human — on a shared kiosk every
// user of the same browser shares one id (intended: it distinguishes devices).
const STORAGE_KEY = 'sirh-device-id'
let cached: string | null = null
export const useDeviceId = (): string | null => {
if (!import.meta.client) {
return null
}
if (cached) {
return cached
}
try {
let id = localStorage.getItem(STORAGE_KEY)
if (!id) {
id = crypto.randomUUID()
localStorage.setItem(STORAGE_KEY, id)
}
cached = id
return id
} catch {
// localStorage unavailable (private mode, disabled) — degrade gracefully.
return null
}
}
```
- [ ] **Step 2: Inject the header in the shared fetch client**
In `frontend/composables/useApi.ts`, the client is created at line 79 with `$fetch.create({ baseURL, retry: 0, credentials: 'include', onResponse(...) {...}, onResponseError(...) {...} })`. Add an `onRequest` interceptor as the first option inside that object (right after `credentials: 'include',`):
```ts
const client = $fetch.create({
baseURL,
retry: 0,
credentials: 'include',
onRequest({ options }) {
const deviceId = useDeviceId()
if (deviceId) {
const headers = new Headers(options.headers as HeadersInit | undefined)
headers.set('X-Device-Id', deviceId)
options.headers = headers
}
},
onResponse({ options, response }) {
```
This covers every call — both `request()` (GET/POST/PUT/PATCH/DELETE) and the `getBlob` path (`client.raw`), since both go through this single client.
- [ ] **Step 3: Verify (no build — review only)**
Do NOT run `npm run build` (project rule). Verify by re-reading the diff:
- `useDeviceId.ts` returns `null` on server (`import.meta.client` guard) and never throws.
- In `useApi.ts`, `onRequest` is a sibling key of `onResponse` inside `$fetch.create({...})`, the braces/commas are balanced, and `options.headers` is reassigned to the merged `Headers`.
Expected: header `X-Device-Id` will be present on all `/api/*` requests once running.
- [ ] **Step 4: Commit**
```bash
git add frontend/composables/useDeviceId.ts frontend/composables/useApi.ts
git commit -m "feat(audit) : envoie un device id persistant sur les requêtes API"
```
---
## Task 6: Config `trusted_proxies` documentée
**Files:**
- Modify: `config/packages/framework.yaml`
**Interfaces:**
- Consumes: rien. Produces: rien (config commentée, comportement inchangé tant que non activée).
- [ ] **Step 1: Add the documented (commented) block**
In `config/packages/framework.yaml`, inside the top-level `framework:` block (after `session: true`, before the `#esi: true` line), insert:
```yaml
# Trusted proxies — REQUIRED for a correct client IP in the activity log
# when SIRH runs behind a reverse proxy (nginx / traefik / cloud LB).
# Without this, Request::getClientIp() returns the PROXY ip, not the client's.
# Uncomment and set to the proxy network/CIDR of your deployment, e.g.:
# trusted_proxies: '127.0.0.1,REMOTE_ADDR,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16'
# trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
# trusted_proxies: '%env(TRUSTED_PROXIES)%'
```
- [ ] **Step 2: Verify config still loads**
Run: `docker exec -t -u www-data php-sirh-fpm php bin/console cache:clear`
Expected: cache cleared, no YAML/config error.
- [ ] **Step 3: Commit**
```bash
git add config/packages/framework.yaml
git commit -m "docs(audit) : documente trusted_proxies pour l'IP du journal"
```
---
## Task 7: Documentation (règles obligatoires)
**Files:**
- Modify: `doc/audit-logging.md`
- Modify: `frontend/data/documentation-content.ts`
- Modify: `CLAUDE.md`
**Interfaces:** N/A.
- [ ] **Step 1: Update `doc/audit-logging.md`**
In the "Données stockées par entrée" section, add the 4 new fields and a note. Append these lines to that list (after `affectedDate` / `createdAt`):
```markdown
- `ipAddress` : IP source de la requête (`Request::getClientIp()`) — nécessite `framework.trusted_proxies` derrière un reverse proxy, sinon IP du proxy
- `userAgent` : User-Agent brut de la requête
- `deviceLabel` : libellé lisible dérivé du User-Agent (`Type · OS · Navigateur`, ex. `Mobile · Android · Chrome`), via `App\Service\UserAgentParser`
- `deviceId` : identifiant d'appareil persistant envoyé par le front (header `X-Device-Id`, stocké en `localStorage['sirh-device-id']`). Distingue les **appareils** derrière un compte partagé (ex. « Usine »), pas les personnes.
Capture : automatique et centralisée dans `AuditLogger::log()` (via `RequestStack`) — aucun processor à modifier. En contexte CLI/cron (pas de requête), ces 4 champs restent `null`.
```
- [ ] **Step 2: Update the in-app documentation**
In `frontend/data/documentation-content.ts`, locate the audit-log / "Journal des actions" article (admin level). Add to its content a block explaining the new forensic columns. Add this block to that article's `blocks` array (follow the existing `DocBlock` shape used in the file — typically `{ type: 'paragraph', text: '...' }` and `{ type: 'list', items: [...] }`):
```ts
{ type: 'paragraph', text: "Chaque entrée du journal enregistre aussi un contexte technique automatique pour distinguer les intervenants sur un compte partagé (ex. « Usine ») :" },
{
type: 'list',
items: [
"Adresse IP de la connexion",
"Appareil / système / navigateur (ex. « Mobile · Android · Chrome »)",
"Identifiant d'appareil : un même appareil garde le même identifiant entre les sessions (distingue les appareils, pas les personnes)",
],
},
```
(If the exact `DocBlock` field names differ — check `frontend/types/documentation.ts` — adapt the keys to match; keep the French copy.)
- [ ] **Step 3: Update `CLAUDE.md`**
In `CLAUDE.md`, in the `## Audit Logging` section, add a bullet:
```markdown
- **Contexte forensique automatique** : chaque entrée capte aussi `ipAddress`, `userAgent` (brut), `deviceLabel` (libellé lisible via `App\Service\UserAgentParser`) et `deviceId` (header `X-Device-Id`, device id persistant `localStorage['sirh-device-id']` envoyé par le front depuis `useApi`/`useDeviceId`). Capture centralisée dans `AuditLogger::log()` via `RequestStack` (null en contexte CLI). But : distinguer les appareils derrière un compte partagé (ex. « Usine »). IP fiable derrière proxy → activer `framework.trusted_proxies`. Affichage écran (`audit-logs.vue`) non couvert (refonte séparée). Doc : `doc/audit-logging.md`.
```
- [ ] **Step 4: Verify docs reference real symbols**
Run: `grep -rn "UserAgentParser\|X-Device-Id\|sirh-device-id" doc/audit-logging.md CLAUDE.md src/ frontend/composables/`
Expected: references resolve to the files created in Tasks 1, 3, 5 (no typos).
- [ ] **Step 5: Commit**
```bash
git add doc/audit-logging.md frontend/data/documentation-content.ts CLAUDE.md
git commit -m "docs(audit) : documente le contexte forensique du journal"
```
---
## Self-Review (auteur du plan)
**Spec coverage :**
- Capture 4 signaux via point unique → Task 3 ✓
- 4 colonnes nullable + migration `down()` → Task 2 ✓
- `UserAgentParser` maison → Task 1 ✓
- Device id front (localStorage) + header sur toutes requêtes → Task 5 ✓
- Exposition API lecture + DTO TS aligné → Task 4 ✓
- `trusted_proxies` documenté/conservateur → Task 6 ✓
- Docs (doc + in-app + CLAUDE.md) + tests → Tasks 1,3,4,7 ✓
- Écran `audit-logs.vue` explicitement hors périmètre → respecté (aucune tâche ne le touche) ✓
**Placeholder scan :** aucun TBD/TODO ; tout le code est fourni. La seule souplesse explicite : Task 7 Step 2 demande d'adapter aux noms de champs réels de `DocBlock` (avec instruction de vérifier `frontend/types/documentation.ts`).
**Type consistency :** getters/setters de Task 2 (`getIpAddress`/`getUserAgent`/`getDeviceLabel`/`getDeviceId`) réutilisés à l'identique dans Tasks 3 et 4. Clés JSON (`ipAddress`/`userAgent`/`deviceLabel`/`deviceId`) identiques entre provider (Task 4 Step 3), test (Task 4 Step 1) et DTO TS (Task 4 Step 5). Header `X-Device-Id` et clé `localStorage` `sirh-device-id` cohérents entre Task 3 (lecture back), Task 5 (écriture front) et docs.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
# Contexte forensique dans le journal d'activité
Date : 2026-06-24
Branche : `feature/SIRH-41-ajouter-plus-d-info-dans-le-journal-d-activite`
## Problème
Le journal d'activité (`audit_logs`) ne stocke comme « qui » que le `username`. Or
certains comptes sont **partagés** par plusieurs personnes (ex. compte « Usine »). Sous
un compte partagé, toutes les actions apparaissent sous le même nom → impossible de
distinguer les intervenants en cas de litige. Les utilisateurs se connectent aussi
depuis des **smartphones**.
## Objectif
Ajouter du **contexte forensique automatique** à chaque entrée du journal, sans rien
demander à l'utilisateur. But : disposer d'assez d'indices techniques pour enquêter
(IP, type d'appareil/OS/navigateur, identifiant d'appareil stable, User-Agent brut) et
distinguer les **appareils** derrière un compte partagé.
Non-objectif (volontairement exclu) : identification explicite de la personne physique
(liste de noms, PIN…). Écarté par l'utilisateur — on reste sur du signal automatique.
## Périmètre
**Inclus :**
- Capture automatique de 4 signaux à chaque écriture d'audit.
- 4 nouvelles colonnes nullable sur `audit_logs` + migration (avec `down()`).
- Service `UserAgentParser` (libellé appareil lisible, sans dépendance externe).
- Front : identifiant d'appareil persistant (`localStorage`) envoyé en header sur toutes
les requêtes API.
- Exposition des 4 champs dans l'API de lecture du journal (`AuditLogResource` / provider)
pour que la future refonte d'écran les ait à disposition.
- Config `framework.trusted_proxies` documentée (conservatrice, à activer selon l'infra).
- Docs (`doc/audit-logging.md`, `documentation-content.ts`, `CLAUDE.md`) + tests unitaires.
**Exclu (étape suivante) :**
- Refonte de l'écran `frontend/pages/audit-logs.vue` (affichage des nouvelles colonnes,
filtre par appareil). L'utilisateur prévoit de revoir cet écran séparément. On se
contente d'exposer les données via l'API ; aucune modif du composant Vue dans ce lot.
## Architecture
### Capture — un seul point d'entrée
Toutes les écritures d'audit passent par `AuditLogger::log()` (`src/Service/AuditLogger.php`).
On y injecte `RequestStack`. À chaque `log()`, on lit la requête courante et on renseigne
les 4 champs sur l'entité `AuditLog` avant persistance. **Aucun processor à modifier.**
Extraction depuis la requête :
- `ip_address``Request::getClientIp()`
- `user_agent` ← header `User-Agent` (brut)
- `device_label``UserAgentParser::parse(userAgent)`
- `device_id` ← header `X-Device-Id`
Si aucune requête courante (ex. commande CLI / cron), les 4 champs restent `null`
(comportement « system » déjà existant pour le username).
### Modèle de données — `audit_logs`
Ajout de 4 colonnes **nullable** (pas de backfill, l'existant reste valide) :
| Colonne | Type | Contenu |
|---|---|---|
| `ip_address` | VARCHAR(45) | IP source. 45 = longueur max IPv6 (avec mapping IPv4). |
| `user_agent` | TEXT | User-Agent brut, stocké tel quel. |
| `device_label` | VARCHAR(255) | Libellé lisible, ex. `Mobile · Android · Chrome`. |
| `device_id` | VARCHAR(64) | UUID persistant fourni par le front. |
Migration Doctrine avec `down()` supprimant les 4 colonnes. Pas de nouvel index dans ce
lot (le filtre par appareil étant reporté à la refonte d'écran).
### Service `UserAgentParser`
Nouveau service `src/Service/UserAgentParser.php`, maison, sans dépendance.
`parse(?string $userAgent): ?string` → libellé court composé de :
- **Type** : Mobile / Tablette / Ordinateur (détecté sur tokens `Mobile`, `Tablet`, `iPad`…).
- **OS** : Android / iOS / Windows / macOS / Linux / autre.
- **Navigateur** : Chrome / Safari / Firefox / Edge / autre (ordre de test important :
Edge avant Chrome, Chrome avant Safari, car les UA s'imbriquent).
Format : `Type · OS · Navigateur` (ex. `Ordinateur · Windows · Firefox`). Retourne `null`
si User-Agent vide. Heuristique volontairement simple et lisible ; suffisant pour
distinguer mobile/poste et familles d'OS. (Alternative écartée : librairie
`matomo/device-detector` — plus précise mais lourde et non nécessaire ici.)
### Front — identifiant d'appareil persistant
- Composable `frontend/composables/useDeviceId.ts` : côté client uniquement, lit
`localStorage['sirh-device-id']` ; si absent, génère `crypto.randomUUID()` et le persiste.
Retourne l'ID (ou `null` côté serveur en SSR).
- `frontend/composables/useApi.ts`, fonction `request()` (point unique de construction des
headers) : `headers.set('X-Device-Id', deviceId)` quand l'ID est disponible. Appliqué à
toutes les méthodes (GET/POST/PUT/PATCH/DELETE).
- Note : l'auth est par cookie JWT (`credentials: 'include'`), donc le device ID n'est pas
lié à l'auth — `localStorage` est ici un usage non sensible, acceptable.
- **Limite assumée** : l'ID est par navigateur/appareil, pas par personne. Sur un poste
partagé (même navigateur), l'ID est identique pour tous → distingue les appareils, pas
les humains. Cohérent avec l'objectif forensique.
### API de lecture
Exposer `ipAddress`, `userAgent`, `deviceLabel`, `deviceId` dans la sortie de lecture du
journal (`src/ApiResource/AuditLogResource.php` + sérialisation dans `AuditLogProvider`),
ainsi que dans le DTO front `frontend/services/dto/audit-log.ts`. Aucune modif du
composant `audit-logs.vue` (refonte ultérieure). Objectif : les données sont prêtes à
être affichées par la future refonte.
### Trusted proxies (IP fiable)
`framework.trusted_proxies` n'est pas configuré aujourd'hui. Derrière un reverse proxy
(nginx/traefik), `getClientIp()` renvoie l'IP du proxy. Architecture de déploiement non
confirmée → on prévoit dans `config/packages/framework.yaml` une entrée **commentée et
documentée** (avec exemple `trusted_proxies` réseau privé / loopback + `trusted_headers`),
à activer selon l'infra. En attendant, l'IP est stockée telle que renvoyée par Symfony.
## Stratégie de test
- `tests/.../UserAgentParserTest` : table de User-Agents réels (Chrome desktop, Safari
iPhone, Chrome Android, Firefox, Edge, UA vide/null) → libellés attendus.
- `tests/.../AuditLoggerTest` : avec un `RequestStack` peuplé d'une `Request` factice
(IP, headers User-Agent + X-Device-Id), vérifier que l'`AuditLog` persisté porte bien les
4 champs ; et qu'avec une `RequestStack` vide (contexte CLI), les 4 champs sont `null`.
## Documentation à mettre à jour (règles obligatoires CLAUDE.md)
- `doc/audit-logging.md` : section « Données stockées par entrée » + nouveaux champs +
note sur le device ID front et le caveat trusted proxies.
- `frontend/data/documentation-content.ts` : doc in-app (niveau admin) du journal.
- `CLAUDE.md` : section Audit Logging — mentionner les 4 nouveaux signaux et le point de
capture unique (`AuditLogger` + `RequestStack`).
## Risques / limites
- Device ID = par appareil, pas par humain (cf. ci-dessus).
- IP peu utile derrière proxy tant que `trusted_proxies` n'est pas activé.
- Plusieurs personnes dans la même usine sortent souvent sur la même IP publique et
peuvent avoir le même modèle de téléphone → les signaux se recoupent ; ce lot fournit
des indices, pas une preuve d'identité.
@@ -0,0 +1,207 @@
# Refonte de l'écran Journal d'activité (MalioDataTable + drawer de filtre)
Date : 2026-06-24
Branche : `feature/SIRH-41-ajouter-plus-d-info-dans-le-journal-d-activite`
## Problème / objectif
L'écran `frontend/pages/audit-logs.vue` (journal d'activité, `ROLE_SUPER_ADMIN`) est aujourd'hui
fait main : `<select>`/`<input>` natifs, tableau en grille CSS, lignes dépliables affichant le diff
JSON brut, pagination « précédent/suivant » figée à 50/page. Il faut le **moderniser** :
1. Passer le tableau en **`MalioDataTable`** (1er usage dans SIRH).
2. Mettre les filtres dans un **drawer**, sur le **même principe que STARSEED** (les écrans de liste
`modules/.../pages/.../index.vue` : `MalioDrawer` + `MalioAccordion`, état brouillon/appliqué,
footer Réinitialiser/Appliquer, badge de compteur de filtres actifs).
3. Passer **tous** les composants de l'écran en composants **Malio** quand l'équivalent existe.
4. Exploiter les nouvelles données forensiques (IP, appareil, User-Agent, device id) déjà captées
par le backend.
## Référence de pattern
- STARSEED, écran canonique : `/home/m-tristan/workspace/Starseed/frontend/modules/commercial/pages/clients/index.vue`
(drawer de filtre, `MalioAccordion`, brouillon→appliqué, `MalioDataTable`, badge compteur).
- Adaptations SIRH : **libellés en français en dur** (convention des drawers SIRH existants —
`employees/index.vue`, `sites.vue` — pas d'i18n comme STARSEED) ; **filtres non persistés en URL**
(comme STARSEED et l'écran actuel).
- Malio `@malio/layer-ui` 1.7.15 (doc `node_modules/@malio/layer-ui/COMPONENTS.md`).
## Périmètre
**Inclus :** refonte complète de `audit-logs.vue` (tableau, filtres, détail) + évolutions backend
nécessaires (perPage + nouveaux filtres) + DTO TS + docs.
**Exclus :** toute autre page ; l'audit reste `ROLE_SUPER_ADMIN` ; pas de doc in-app (outil caché,
aucun article existant — décision déjà prise au lot précédent).
---
## A. Tableau — `MalioDataTable`
API (1.7.15) : `:columns` (`{key,label}[]`), `:items`, `:total-items`, `v-model:page`,
`v-model:per-page`, `:per-page-options`, `row-clickable`, événements `row-click` /
`update:page` / `update:per-page`, slots `#cell-{key}` et `#empty`.
Colonnes :
| key | label | rendu |
|---|---|---|
| `createdAt` | Date action | `JJ/MM/AAAA HH:MM` (déjà formaté par le provider) |
| `username` | Utilisateur | texte brut |
| `action` | Action | badge couleur via `#cell-action` (create=vert, update=bleu, delete=rouge, validate=violet, site_validate=indigo, défaut=neutre) |
| `entityType` | Type | libellé FR via `#cell-entityType` (work_hour→Heures, absence→Absence, employee→Employé, contract_suspension→Suspension, rtt_payment→RTT, fractioned_days→Fract., paid_leave_days→Congés payés, week_comment→Commentaire) |
| `employeeName` | Employé | nom ou `—` |
| `deviceLabel` | Appareil | `deviceLabel` ou `—` |
| `description` | Description | tronqué (`truncate` + `title`) via `#cell-description` |
- `:per-page-options="[25, 50, 100]"`, `perPage` par défaut 50.
- `@row-click` → ouvre le drawer de détail avec la ligne cliquée.
- `:items` = directement les `AuditLog` de la page courante (le DTO porte déjà toutes les clés ;
les `key` de colonnes correspondent aux champs).
## B. Drawer de détail (clic ligne)
`MalioDrawer` (droite, `drawer-class="max-w-xl"`), titre `#header` = « Détail de l'action ».
Contenu (lecture seule, sections) :
- **Méta** : Utilisateur, Employé, Date action, Date affectée, Action (badge), Type (libellé).
- **Contexte technique** : IP (`ipAddress`), Appareil (`deviceLabel`), User-Agent brut
(`userAgent`, en `break-all`/petite police), Device id (`deviceId`). Champs nuls → `—`.
- **Changements** : si `changes` non nul, rendu lisible — pour chaque clé présente dans
`old`/`new`, une ligne `clé : ancienne → nouvelle` (au lieu du double bloc JSON brut actuel).
Helper front `formatChanges(changes)` qui fusionne les clés de `old` et `new`. Si `changes` nul →
« Aucun détail de modification ».
État : `selectedLog: AuditLog | null` + `detailOpen: boolean`. Fermeture standard MalioDrawer.
## C. Drawer de filtre (principe STARSEED)
Bouton **« Filtrer »** (`MalioButton variant="tertiary" icon-name="mdi:tune"`) dans la barre de titre ;
son label porte le **compteur de filtres actifs** (`Filtrer (N)` si N>0).
`MalioDrawer` (`drawer-class="max-w-[450px]"`, `body-class="p-0"`,
`footer-class="justify-between border-t border-black p-6"`), titre `#header` = « Filtres ».
Corps en `MalioAccordion` (un `MalioAccordionItem` par section) :
| Section | Composant | Champ filtre |
|---|---|---|
| Période | `MalioDateRange` (`v-model` = `{start,end}` ISO) | `from`/`to` sur `affectedDate` (sémantique actuelle conservée) |
| Employé | `MalioSelect` (options = employés chargés au mount) | `employeeId` (valeur unique) |
| Type d'entité | liste de `MalioCheckbox` (multi) | `entityType[]` |
| Action | liste de `MalioCheckbox` (multi) | `action[]` |
| Utilisateur / compte | `MalioInputText` (`icon mdi:magnify`) | `username` (ILIKE partiel) |
| IP | `MalioInputText` | `ip` (ILIKE partiel) |
| Appareil | `MalioInputText` | `device` (ILIKE partiel sur `device_label` OU `device_id`) |
Footer : `MalioButton variant="tertiary"` **Réinitialiser** (gauche) + `MalioButton variant="primary"`
**Appliquer** (droite).
**État brouillon → appliqué** (pattern STARSEED) :
- `draft*` refs (éditées dans le drawer) et `applied*` refs (pilotent le fetch).
- `openFilters()` : copie `applied*``draft*` puis ouvre.
- `applyFilters()` : copie `draft*``applied*`, remet `page=1`, refetch, ferme le drawer.
- `resetFilters()` : vide `draft*` **et** `applied*`, remet `page=1`, refetch, **laisse le drawer ouvert**.
- `activeFilterCount` (computed sur `applied*`) → badge bouton.
- Helpers `toggle(arrayRef, value, selected)` pour les multi-select.
- Options Type d'entité / Action = listes statiques (mêmes codes que le provider) ; options Employé
chargées une fois au `onMounted` (réutiliser le chargement employés déjà fait par l'écran actuel).
## D. Composable `useAuditLogsList`
Composable **spécifique à l'écran** (`frontend/composables/useAuditLogsList.ts`) — pas de
`usePaginatedList` générique (un seul consommateur → YAGNI). Expose :
- état : `items`, `total`, `page`, `perPage`, `loading`, les `draft*`/`applied*`, `activeFilterCount`,
`employeeOptions`.
- actions : `load()` (fetch avec filtres appliqués + page/perPage), `goToPage(n)`, `setPerPage(n)`,
`openFilters()`, `applyFilters()`, `resetFilters()`, `loadEmployeeOptions()`.
- `load()` doit ignorer les réponses périmées (garde anti-race : compteur de requête, on jette
les réponses dont l'index n'est pas le dernier émis).
La page `audit-logs.vue` se réduit à : barre de titre (titre + bouton Filtrer), `MalioDataTable`,
drawer filtre, drawer détail — toute la logique vit dans le composable.
## E. Backend
### `frontend/services/dto/audit-log.ts` (`AuditLogFilters`)
Étendre :
```ts
export type AuditLogFilters = {
employeeId?: number
from?: string
to?: string
entityType?: string[]
action?: string[]
username?: string
ip?: string
device?: string
page?: number
perPage?: number
}
```
`fetchAuditLogs` sérialise les tableaux en `entityType[]`/`action[]` (syntaxe PHP) et n'inclut que
les filtres non vides.
### `src/ApiResource/AuditLogResource.php`
Ajouter les `QueryParameter` : `perPage`, `username`, `ip`, `device`, `action` (`entityType` existe
déjà). (Les `QueryParameter` sont surtout documentaires : le provider lit `$request->query`.)
### `src/State/AuditLogProvider.php`
- Lire `perPage` (défaut 50, clampé à un ensemble autorisé `[25,50,100]`, fallback 50 ; borne dure).
- Lire `username`, `ip`, `device` (chaînes, `null` si vide).
- Lire `entityType` et `action` en **tableaux** (`$request->query->all('entityType')` /
`->all('action')`), `null`/`[]` si absent. Conserver la rétro-compat : si `entityType` arrive en
scalaire, le normaliser en tableau à un élément.
- Passer le tout au repository ; `perPage` remplace la constante `PER_PAGE`. La réponse renvoie
`perPage` réel.
### `src/Repository/Contract/AuditLogReadRepositoryInterface.php` + `AuditLogRepository.php`
Faire évoluer `findByFilters` / `countByFilters` :
```php
findByFilters(
?int $employeeId,
?DateTimeImmutable $from,
?DateTimeImmutable $to,
?array $entityTypes, // list<string>|null
?array $actions, // list<string>|null
?string $username,
?string $ip,
?string $device,
int $limit,
int $offset,
): array
countByFilters(... mêmes filtres ...): int
```
Clauses : `employeeId` =, dates BETWEEN sur `affectedDate` (inchangé), `entityTypes`/`actions`
`IN (:...)` si non vides, `username`/`ip` `ILIKE %v%` (paramètre échappé), `device`
`(device_label ILIKE :d OR device_id ILIKE :d)`. Tri inchangé (`createdAt DESC`).
Mutualiser la construction des critères entre les deux méthodes (méthode privée
`applyFilters(QueryBuilder, ...)`) pour rester DRY.
## Tests
- Backend : `AuditLogProviderTest` étendu — vérifier que `perPage`, `username`, `ip`, `device`,
`entityType[]`, `action[]` sont lus et transmis au repository (repo stubbé, on asserte les
arguments via un spy), et que `perPage` hors liste retombe sur 50.
- Backend : test repository des nouvelles clauses si un test repository existe ; sinon couvrir via le
provider (le repo réel n'est pas unit-testé aujourd'hui — ne pas introduire d'intégration DB).
- Front : pas de test auto (convention SIRH, pas de build) — revue de diff. Le composable
`useAuditLogsList` reste pur/réactif et testable manuellement.
## Documentation
- `doc/audit-logging.md` : section « Filtres disponibles » mise à jour (employé, période, type[],
action[], utilisateur, IP, appareil ; pagination perPage) + mention du drawer et du drawer de
détail.
- `CLAUDE.md` : compléter la puce « Contexte forensique » / journal pour noter l'écran refondu
(`MalioDataTable`, drawer de filtre façon STARSEED, drawer de détail, filtres back
username/ip/device/action[]/entityType[]/perPage).
## Risques / notes
- 1er `MalioDataTable` de SIRH : valider le rendu (le composant gère sa propre pagination/markup ;
ne pas réappliquer le gabarit grille maison du CLAUDE.md à ce tableau).
- `MalioDateRange` filtre `affectedDate` (cohérent avec l'existant) ; ne pas confondre avec
`createdAt` (date d'action affichée en colonne).
- Évolution de signature de `AuditLogReadRepositoryInterface` : mettre à jour l'implémentation et le
provider dans le même lot (ils sont les seuls consommateurs).
+8
View File
@@ -80,6 +80,14 @@ export const useApi = (): ApiClient => {
baseURL,
retry: 0,
credentials: 'include',
onRequest({ options }) {
const deviceId = useDeviceId()
if (deviceId) {
const headers = new Headers(options.headers as HeadersInit | undefined)
headers.set('X-Device-Id', deviceId)
options.headers = headers
}
},
onResponse({ options, response }) {
const apiOptions = options as ApiFetchOptions<'json'>
if (apiOptions?.toast === false) {
+144
View File
@@ -0,0 +1,144 @@
import { ref, computed } from 'vue'
import type { AuditLog } from '~/services/dto/audit-log'
import { fetchAuditLogs, type AuditLogFilters } from '~/services/audit-logs'
type Range = { start: string, end: string } | null
export const useAuditLogsList = () => {
const items = ref<AuditLog[]>([])
const total = ref(0)
const page = ref(1)
const perPage = ref(10)
const loading = ref(false)
const filterOpen = ref(false)
// Applied filters (drive the fetch)
const appliedEmployee = ref('')
const appliedRange = ref<Range>(null)
const appliedEntityTypes = ref<string[]>([])
const appliedActions = ref<string[]>([])
const appliedUsername = ref('')
const appliedIp = ref('')
const appliedDevice = ref('')
// Draft filters (edited inside the drawer)
const draftEmployee = ref('')
const draftRange = ref<Range>(null)
const draftEntityTypes = ref<string[]>([])
const draftActions = ref<string[]>([])
const draftUsername = ref('')
const draftIp = ref('')
const draftDevice = ref('')
const activeFilterCount = computed(() => {
let n = 0
if (appliedEmployee.value.trim() !== '') n++
if (appliedRange.value?.start || appliedRange.value?.end) n++
if (appliedEntityTypes.value.length > 0) n++
if (appliedActions.value.length > 0) n++
if (appliedUsername.value.trim() !== '') n++
if (appliedIp.value.trim() !== '') n++
if (appliedDevice.value.trim() !== '') n++
return n
})
const buildFilters = (): AuditLogFilters => ({
employee: appliedEmployee.value.trim() || undefined,
from: appliedRange.value?.start || undefined,
to: appliedRange.value?.end || undefined,
entityType: appliedEntityTypes.value.length > 0 ? [...appliedEntityTypes.value] : undefined,
action: appliedActions.value.length > 0 ? [...appliedActions.value] : undefined,
username: appliedUsername.value.trim() || undefined,
ip: appliedIp.value.trim() || undefined,
device: appliedDevice.value.trim() || undefined,
page: page.value,
perPage: perPage.value,
})
// Race guard: only the latest request may commit its result.
let requestSeq = 0
const load = async () => {
const seq = ++requestSeq
loading.value = true
try {
const result = await fetchAuditLogs(buildFilters())
if (seq !== requestSeq) return
items.value = result.items
total.value = result.total
page.value = result.page
perPage.value = result.perPage
} finally {
if (seq === requestSeq) loading.value = false
}
}
const init = async () => {
await load()
}
const goToPage = (n: number) => {
page.value = n
load()
}
const setPerPage = (n: number) => {
perPage.value = n
page.value = 1
load()
}
const openFilters = () => {
draftEmployee.value = appliedEmployee.value
draftRange.value = appliedRange.value ? { ...appliedRange.value } : null
draftEntityTypes.value = [...appliedEntityTypes.value]
draftActions.value = [...appliedActions.value]
draftUsername.value = appliedUsername.value
draftIp.value = appliedIp.value
draftDevice.value = appliedDevice.value
filterOpen.value = true
}
const applyFilters = () => {
appliedEmployee.value = draftEmployee.value
appliedRange.value = draftRange.value ? { ...draftRange.value } : null
appliedEntityTypes.value = [...draftEntityTypes.value]
appliedActions.value = [...draftActions.value]
appliedUsername.value = draftUsername.value
appliedIp.value = draftIp.value
appliedDevice.value = draftDevice.value
page.value = 1
filterOpen.value = false
load()
}
const resetFilters = () => {
draftEmployee.value = ''
draftRange.value = null
draftEntityTypes.value = []
draftActions.value = []
draftUsername.value = ''
draftIp.value = ''
draftDevice.value = ''
appliedEmployee.value = ''
appliedRange.value = null
appliedEntityTypes.value = []
appliedActions.value = []
appliedUsername.value = ''
appliedIp.value = ''
appliedDevice.value = ''
page.value = 1
load() // drawer stays open
}
const toggle = (arr: typeof draftEntityTypes, value: string, selected: boolean) => {
arr.value = selected ? [...arr.value, value] : arr.value.filter(v => v !== value)
}
const toggleEntityType = (value: string, selected: boolean) => toggle(draftEntityTypes, value, selected)
const toggleAction = (value: string, selected: boolean) => toggle(draftActions, value, selected)
return {
items, total, page, perPage, loading, filterOpen, activeFilterCount,
draftEmployee, draftRange, draftEntityTypes, draftActions, draftUsername, draftIp, draftDevice,
init, goToPage, setPerPage, openFilters, applyFilters, resetFilters, toggleEntityType, toggleAction,
}
}
+28
View File
@@ -0,0 +1,28 @@
// Stable per-device identifier used to add forensic context to audit logs.
// Persisted in localStorage so the same browser/device reuses it across sessions.
// NOTE: this identifies a device/browser, not a human — on a shared kiosk every
// user of the same browser shares one id (intended: it distinguishes devices).
const STORAGE_KEY = 'sirh-device-id'
let cached: string | null = null
export const useDeviceId = (): string | null => {
if (!import.meta.client) {
return null
}
if (cached) {
return cached
}
try {
let id = localStorage.getItem(STORAGE_KEY)
if (!id) {
id = crypto.randomUUID()
localStorage.setItem(STORAGE_KEY, id)
}
cached = id
return id
} catch {
// localStorage unavailable (private mode, disabled) — degrade gracefully.
return null
}
}
+45 -26
View File
@@ -48,6 +48,11 @@ export const useDriverHoursPage = () => {
const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false)
const rows = ref<Record<number, DriverHourRow>>({})
// Instantané des lignes telles que chargées depuis le serveur (clé = employeeId).
// Sert à n'envoyer au bulk-upsert que les lignes réellement modifiées, afin de ne jamais
// écraser/supprimer une ligne saisie entre-temps par un autre utilisateur (enregistrement
// « à l'aveugle » d'une grille périmée).
const loadedRows = ref<Record<number, DriverHourRow>>({})
const dayContext = ref<WorkHourDayContext | null>(null)
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
const absenceTypes = ref<AbsenceType[]>([])
@@ -458,6 +463,10 @@ export const useDriverHoursPage = () => {
}
rows.value = nextRows
// Clone indépendant : les éditions mutent les objets de rows.value, pas ceux-ci.
loadedRows.value = Object.fromEntries(
Object.entries(nextRows).map(([employeeId, row]) => [employeeId, { ...row }])
)
}
const loadAbsenceTypes = async () => {
@@ -924,6 +933,32 @@ export const useDriverHoursPage = () => {
await refreshByDate()
})
// Construit l'entrée bulk-upsert à partir d'une ligne (état courant OU instantané chargé).
const buildEntry = (employeeId: number, row: DriverHourRow) => {
const dayMin = toMinutes(row.dayHours)
const nightMin = toMinutes(row.nightHours)
const workshopMin = toMinutes(row.workshopHours)
return {
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false,
dayHoursMinutes: dayMin || null,
nightHoursMinutes: nightMin || null,
workshopHoursMinutes: workshopMin || null,
hasBreakfast: row.hasBreakfast,
hasLunch: row.hasLunch,
hasDinner: row.hasDinner,
hasOvernight: row.hasOvernight
}
}
const handleSave = async () => {
if (isSubmitting.value || employees.value.length === 0) return
@@ -933,32 +968,16 @@ export const useDriverHoursPage = () => {
(e) => e.isDriver === true && hasContractAtSelectedDate(e.id)
)
const entries = driverEmployees.map((employee) => {
const employeeId = employee.id
const row = rows.value[employeeId] ?? emptyRow()
const dayMin = toMinutes(row.dayHours)
const nightMin = toMinutes(row.nightHours)
const workshopMin = toMinutes(row.workshopHours)
return {
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false,
dayHoursMinutes: dayMin || null,
nightHoursMinutes: nightMin || null,
workshopHoursMinutes: workshopMin || null,
hasBreakfast: row.hasBreakfast,
hasLunch: row.hasLunch,
hasDinner: row.hasDinner,
hasOvernight: row.hasOvernight
}
})
const entries = driverEmployees
.map((employee) => {
const current = buildEntry(employee.id, rows.value[employee.id] ?? emptyRow())
const original = buildEntry(employee.id, loadedRows.value[employee.id] ?? emptyRow())
return { current, original }
})
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
.map(({ current }) => current)
if (entries.length === 0) return
+46 -27
View File
@@ -50,6 +50,11 @@ export const useHoursPage = () => {
const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false)
const rows = ref<Record<number, HourRow>>({})
// Instantané des lignes telles que chargées depuis le serveur (clé = employeeId).
// Sert à n'envoyer au bulk-upsert que les lignes réellement modifiées par l'utilisateur,
// afin de ne jamais écraser/supprimer une ligne saisie entre-temps par un autre utilisateur
// (perte de données par enregistrement « à l'aveugle » d'une grille périmée).
const loadedRows = ref<Record<number, HourRow>>({})
const dayContext = ref<WorkHourDayContext | null>(null)
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
const absenceTypes = ref<AbsenceType[]>([])
@@ -600,6 +605,10 @@ export const useHoursPage = () => {
}
rows.value = nextRows
// Clone indépendant : les éditions mutent les objets de rows.value, pas ceux-ci.
loadedRows.value = Object.fromEntries(
Object.entries(nextRows).map(([employeeId, row]) => [employeeId, { ...row }])
)
}
const loadAbsenceTypes = async () => {
@@ -1136,6 +1145,36 @@ export const useHoursPage = () => {
await refreshByDate()
})
// Construit l'entrée bulk-upsert à partir d'une ligne (état courant OU instantané chargé).
const buildEntry = (employee: Employee, row: HourRow) => {
const employeeId = employee.id
if (isPresenceTracking(employee)) {
return {
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: row.isPresentMorning,
isPresentAfternoon: row.isPresentAfternoon
}
}
return {
employeeId,
morningFrom: normalizeTime(row.morningFrom),
morningTo: normalizeTime(row.morningTo),
afternoonFrom: normalizeTime(row.afternoonFrom),
afternoonTo: normalizeTime(row.afternoonTo),
eveningFrom: normalizeTime(row.eveningFrom),
eveningTo: normalizeTime(row.eveningTo),
isPresentMorning: false,
isPresentAfternoon: false
}
}
const handleSave = async () => {
if (isSubmitting.value || employees.value.length === 0) return
@@ -1144,34 +1183,14 @@ export const useHoursPage = () => {
const entries = employees.value
.filter((employee) => hasContractAtSelectedDate(employee.id) && !isRowLocked(employee.id))
.map((employee) => {
const employeeId = employee.id
const row = rows.value[employeeId] ?? emptyRow()
if (isPresenceTracking(employee)) {
return {
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: row.isPresentMorning,
isPresentAfternoon: row.isPresentAfternoon
}
}
return {
employeeId,
morningFrom: normalizeTime(row.morningFrom),
morningTo: normalizeTime(row.morningTo),
afternoonFrom: normalizeTime(row.afternoonFrom),
afternoonTo: normalizeTime(row.afternoonTo),
eveningFrom: normalizeTime(row.eveningFrom),
eveningTo: normalizeTime(row.eveningTo),
isPresentMorning: false,
isPresentAfternoon: false
}
const current = buildEntry(employee, rows.value[employee.id] ?? emptyRow())
const original = buildEntry(employee, loadedRows.value[employee.id] ?? emptyRow())
return { current, original }
})
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
.map(({ current }) => current)
if (entries.length === 0) {
return
+2
View File
@@ -416,6 +416,8 @@ export const documentationSections: DocSection[] = [
{ type: 'paragraph', content: 'Les absences peuvent être posées depuis la vue jour des heures ou depuis le calendrier.' },
{ type: 'list', content: 'Journée complète : efface toutes les plages horaires\nDemi-journée matin (AM) : efface le créneau matin\nDemi-journée après-midi (PM) : efface les créneaux après-midi et soir' },
{ type: 'paragraph', content: 'Les absences sont stockées par jour : une absence de plusieurs jours est automatiquement découpée en entrées quotidiennes.' },
{ type: 'note', content: 'Supprimer une plage depuis le calendrier : cliquez sur un jour de la plage (le drawer s\'ouvre sur ce jour), étendez la date de fin (ou de début) pour couvrir toute la plage à effacer, puis cliquez sur « Supprimer ». Tous les jours de congé compris dans la plage sélectionnée sont supprimés en une fois ; les jours sans absence dans cette plage sont simplement ignorés. Un jour déjà validé reste protégé.' },
{ type: 'note', content: 'Modifier une plage depuis le calendrier : cliquez sur le premier jour de la plage, ajustez la date de fin (pour la raccourcir ou l\'allonger) puis « Modifier ». La plage est remplacée proprement par la nouvelle : plus de jours « fantômes » qui restaient après un raccourcissement, ni de doublons après un allongement. Les jours situés avant le jour cliqué ne sont jamais modifiés.' },
],
},
{
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.7.11",
"@malio/layer-ui": "^1.7.15",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
@@ -2247,9 +2247,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.7.11",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.11/layer-ui-1.7.11.tgz",
"integrity": "sha512-uTISSe0L2T0TcpJShdK8VOEr0GpYzyDFDkLNFRa5APbpnfb8GPchx0xlFA1pgEF7DbnYB/zxYTWZCrGOhmaWOQ==",
"version": "1.7.15",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.15/layer-ui-1.7.15.tgz",
"integrity": "sha512-CgEC0l2pkR6rlzpi1zZqswHs+/yGTSd861tdT678/wSKtQPQ6JxUIf63ugFDItyvyLW+nbcNWuHTFC2Bimp1EQ==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -11,7 +11,7 @@
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
},
"dependencies": {
"@malio/layer-ui": "^1.7.11",
"@malio/layer-ui": "^1.7.15",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
+208 -220
View File
@@ -1,254 +1,242 @@
<template>
<div class="h-full flex flex-col overflow-hidden">
<h1 class="text-4xl font-bold text-primary-500 pb-6">Journal des actions</h1>
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Journal des actions</h1>
<MalioButton
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
@click="list.openFilters()"
/>
</div>
<div class="flex items-end gap-4 pb-6 flex-wrap">
<div>
<label class="text-md font-semibold text-neutral-700">Employé</label>
<select
v-model="filters.employeeId"
class="mt-2 h-[42px] w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
>
<option :value="undefined">Tous</option>
<option v-for="emp in employees" :key="emp.id" :value="emp.id">
{{ emp.lastName }} {{ emp.firstName }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">Du</label>
<input
v-model="filters.from"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">Au</label>
<input
v-model="filters.to"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">Type</label>
<select
v-model="filters.entityType"
class="mt-2 h-[42px] w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
>
<option :value="undefined">Tous</option>
<option value="work_hour">Heures</option>
<option value="absence">Absences</option>
<option value="employee">Employé</option>
<option value="contract_suspension">Suspension</option>
<option value="rtt_payment">Paiement RTT</option>
<option value="fractioned_days">Jours fractionnés</option>
<option value="paid_leave_days">Congés N-1 payés</option>
</select>
</div>
<button
type="button"
class="h-[42px] rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="search"
<div class="min-h-0 flex-1 overflow-auto">
<MalioDataTable
:columns="columns"
:items="list.items.value"
:total-items="list.total.value"
:page="list.page.value"
:per-page="list.perPage.value"
:per-page-options="[10, 25, 50, 100]"
empty-message="Aucune entrée trouvée."
@row-click="openDetail"
@update:page="list.goToPage($event)"
@update:per-page="list.setPerPage($event)"
>
Rechercher
</button>
<template #cell-createdAt="{ item }">
{{ formatDateTime((item as AuditLog).createdAt) }}
</template>
<template #cell-action="{ item }">
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass((item as AuditLog).action)">
{{ actionLabel((item as AuditLog).action) }}
</span>
</template>
<template #cell-entityType="{ item }">
{{ entityTypeLabel((item as AuditLog).entityType) }}
</template>
<template #cell-employeeName="{ item }">
{{ (item as AuditLog).employeeName ?? '—' }}
</template>
<template #cell-deviceLabel="{ item }">
{{ (item as AuditLog).deviceLabel ?? '—' }}
</template>
<template #cell-description="{ item }">
<span class="block max-w-[320px] truncate" :title="(item as AuditLog).description">{{ (item as AuditLog).description }}</span>
</template>
</MalioDataTable>
</div>
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<!-- Filter drawer -->
<MalioDrawer
v-model="list.filterOpen.value"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[32px] font-semibold text-primary-500">Filtres</h2>
</template>
<div v-else-if="logs.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Aucune entrée trouvée.
</div>
<MalioAccordion>
<MalioAccordionItem title="Période" value="period">
<MalioDateRange v-model="list.draftRange.value" clearable />
</MalioAccordionItem>
<template v-else>
<div class="min-h-0 flex-1 overflow-auto rounded-md bg-white">
<div class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span>Date action</span>
<span>Utilisateur</span>
<span>Action</span>
<span>Type</span>
<span>Employé</span>
<span>Description</span>
<span>Date affectée</span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<template v-for="log in logs" :key="log.id">
<div
class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="toggleExpand(log.id)"
>
<span>{{ formatDateTime(log.createdAt) }}</span>
<span>{{ log.username }}</span>
<span>
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(log.action)">
{{ actionLabel(log.action) }}
</span>
</span>
<span>{{ entityTypeLabel(log.entityType) }}</span>
<span>{{ log.employeeName ?? '-' }}</span>
<span class="truncate font-normal" :title="log.description">{{ log.description }}</span>
<span>{{ log.affectedDate ? formatDate(log.affectedDate) : '-' }}</span>
<MalioAccordionItem title="Employé" value="employee">
<MalioInputText v-model="list.draftEmployee.value" icon-name="mdi:magnify" />
</MalioAccordionItem>
<MalioAccordionItem title="Type d'entité" value="entityType">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in entityTypeOptions"
:id="`filter-type-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="list.draftEntityTypes.value.includes(opt.value)"
@update:model-value="(val: boolean) => list.toggleEntityType(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<MalioAccordionItem title="Action" value="action">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in actionOptions"
:id="`filter-action-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="list.draftActions.value.includes(opt.value)"
@update:model-value="(val: boolean) => list.toggleAction(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<MalioAccordionItem title="Utilisateur / compte" value="username">
<MalioInputText v-model="list.draftUsername.value" icon-name="mdi:magnify" />
</MalioAccordionItem>
<MalioAccordionItem title="IP" value="ip">
<MalioInputText v-model="list.draftIp.value" icon-name="mdi:magnify" />
</MalioAccordionItem>
<MalioAccordionItem title="Appareil" value="device">
<MalioInputText v-model="list.draftDevice.value" icon-name="mdi:magnify" />
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton variant="tertiary" label="Réinitialiser" @click="list.resetFilters()" />
<MalioButton variant="primary" label="Appliquer" button-class="w-[170px]" @click="list.applyFilters()" />
</template>
</MalioDrawer>
<!-- Detail drawer -->
<MalioDrawer v-model="detailOpen" drawer-class="max-w-xl">
<template #header>
<h2 class="text-[32px] font-semibold text-primary-500">Détail de l'action</h2>
</template>
<div v-if="selected" class="space-y-6 text-md text-primary-500">
<section class="space-y-1">
<p><span class="font-semibold">Utilisateur :</span> {{ selected.username }}</p>
<p><span class="font-semibold">Employé :</span> {{ selected.employeeName ?? '—' }}</p>
<p><span class="font-semibold">Date action :</span> {{ formatDateTime(selected.createdAt) }}</p>
<p><span class="font-semibold">Date affectée :</span> {{ selected.affectedDate ? formatDate(selected.affectedDate) : '—' }}</p>
<p>
<span class="font-semibold">Action :</span>
<span class="ml-1 rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(selected.action)">{{ actionLabel(selected.action) }}</span>
</p>
<p><span class="font-semibold">Type :</span> {{ entityTypeLabel(selected.entityType) }}</p>
</section>
<section class="space-y-1">
<h3 class="font-bold">Contexte technique</h3>
<p><span class="font-semibold">IP :</span> {{ selected.ipAddress ?? '—' }}</p>
<p><span class="font-semibold">Appareil :</span> {{ selected.deviceLabel ?? '—' }}</p>
<p><span class="font-semibold">User-Agent :</span> <span class="break-all text-sm font-normal">{{ selected.userAgent ?? '—' }}</span></p>
<p><span class="font-semibold">Device id :</span> <span class="break-all text-sm font-normal">{{ selected.deviceId ?? '—' }}</span></p>
</section>
<section class="space-y-1">
<h3 class="font-bold">Changements</h3>
<div v-if="changeRows.length > 0" class="space-y-1">
<div v-for="row in changeRows" :key="row.key" class="text-sm">
<span class="font-semibold">{{ row.key }} :</span>
<span class="text-red-600">{{ row.old }}</span>
<span class="px-1"></span>
<span class="text-green-600">{{ row.new }}</span>
</div>
<div
v-if="expandedIds.has(log.id)"
class="border-b border-primary-500 px-6 py-4 bg-neutral-50"
>
<div v-if="log.changes" class="grid grid-cols-2 gap-6 text-sm font-mono">
<div v-if="log.changes.old">
<p class="font-bold text-red-600 mb-2">Ancien</p>
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.old, null, 2) }}</pre>
</div>
<div v-if="log.changes.new">
<p class="font-bold text-green-600 mb-2">Nouveau</p>
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.new, null, 2) }}</pre>
</div>
</div>
<p v-else class="text-md text-neutral-400">Pas de détail disponible.</p>
</div>
</template>
</div>
</div>
<p v-else class="text-sm font-normal text-neutral-400">Aucun détail de modification.</p>
</section>
</div>
<div class="flex items-center justify-between pt-4">
<p class="text-md text-neutral-500">
{{ total }} résultat{{ total > 1 ? 's' : '' }} page {{ currentPage }}/{{ totalPages }}
</p>
<div class="flex gap-3">
<button
type="button"
:disabled="currentPage <= 1"
class="rounded-lg border border-primary-500 px-4 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500 disabled:opacity-30 disabled:cursor-not-allowed"
@click="goToPage(currentPage - 1)"
>
Précédent
</button>
<button
type="button"
:disabled="currentPage >= totalPages"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-30 disabled:cursor-not-allowed"
@click="goToPage(currentPage + 1)"
>
Suivant
</button>
</div>
</div>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import type { AuditLog } from '~/services/dto/audit-log'
import type { Employee } from '~/services/dto/employee'
import { fetchAuditLogs } from '~/services/audit-logs'
import { listEmployees } from '~/services/employees'
definePageMeta({
middleware: 'super-admin'
})
import { useAuditLogsList } from '~/composables/useAuditLogsList'
definePageMeta({ middleware: 'super-admin' })
useHead({ title: 'Journal des actions' })
const logs = ref<AuditLog[]>([])
const employees = ref<Employee[]>([])
const isLoading = ref(false)
const expandedIds = ref(new Set<number>())
const total = ref(0)
const currentPage = ref(1)
const perPage = ref(50)
const list = useAuditLogsList()
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)))
const columns = [
{ key: 'createdAt', label: 'Date action' },
{ key: 'username', label: 'Utilisateur' },
{ key: 'action', label: 'Action' },
{ key: 'entityType', label: 'Type' },
{ key: 'employeeName', label: 'Employé' },
{ key: 'deviceLabel', label: 'Appareil' },
{ key: 'description', label: 'Description' },
]
const filters = reactive<{
employeeId?: number
from?: string
to?: string
entityType?: string
}>({})
const entityTypeOptions = [
{ value: 'work_hour', label: 'Heures' },
{ value: 'absence', label: 'Absence' },
{ value: 'employee', label: 'Employé' },
{ value: 'contract_suspension', label: 'Suspension' },
{ value: 'rtt_payment', label: 'RTT' },
{ value: 'fractioned_days', label: 'Fract.' },
{ value: 'paid_leave_days', label: 'Congés payés' },
{ value: 'week_comment', label: 'Commentaire' },
]
const loadLogs = async (page = 1) => {
isLoading.value = true
try {
const result = await fetchAuditLogs({ ...filters, page })
logs.value = result.items
total.value = result.total
currentPage.value = result.page
perPage.value = result.perPage
expandedIds.value.clear()
} finally {
isLoading.value = false
}
const actionOptions = [
{ value: 'create', label: 'Créer' },
{ value: 'update', label: 'Modifier' },
{ value: 'delete', label: 'Supprimer' },
{ value: 'validate', label: 'Valider' },
{ value: 'site_validate', label: 'Valider (site)' },
]
const filterButtonLabel = computed(() =>
list.activeFilterCount.value > 0 ? `Filtrer (${list.activeFilterCount.value})` : 'Filtrer',
)
// Detail drawer
const detailOpen = ref(false)
const selected = ref<AuditLog | null>(null)
const openDetail = (item: Record<string, unknown>) => {
selected.value = item as unknown as AuditLog
detailOpen.value = true
}
const search = () => {
loadLogs(1)
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
loadLogs(page)
}
}
const toggleExpand = (id: number) => {
if (expandedIds.value.has(id)) {
expandedIds.value.delete(id)
} else {
expandedIds.value.add(id)
}
}
const changeRows = computed(() => {
const c = selected.value?.changes
if (!c) return []
const keys = new Set<string>([...Object.keys(c.old ?? {}), ...Object.keys(c.new ?? {})])
return [...keys].map(key => ({
key,
old: c.old?.[key] === undefined ? '—' : JSON.stringify(c.old[key]),
new: c.new?.[key] === undefined ? '—' : JSON.stringify(c.new[key]),
}))
})
const formatDateTime = (dt: string) => {
const d = new Date(dt)
return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
}
const formatDate = (d: string) => {
return d.split('-').reverse().join('/')
}
const formatDate = (d: string) => d.split('-').reverse().join('/')
const actionLabel = (action: string): string => {
const map: Record<string, string> = {
create: 'Créer',
update: 'Modifier',
delete: 'Suppr.',
validate: 'Valid.',
site_validate: 'Valid. site',
}
return map[action] ?? action
}
const actionLabel = (action: string): string => ({
create: 'Créer', update: 'Modifier', delete: 'Suppr.', validate: 'Valid.', site_validate: 'Valid. site',
}[action] ?? action)
const actionClass = (action: string): string => {
const map: Record<string, string> = {
create: 'bg-green-500',
update: 'bg-blue-500',
delete: 'bg-red-500',
validate: 'bg-purple-500',
site_validate: 'bg-indigo-500',
}
return map[action] ?? 'bg-neutral-500'
}
const actionClass = (action: string): string => ({
create: 'bg-green-500', update: 'bg-blue-500', delete: 'bg-red-500', validate: 'bg-purple-500', site_validate: 'bg-indigo-500',
}[action] ?? 'bg-neutral-500')
const entityTypeLabel = (type: string): string => {
const map: Record<string, string> = {
work_hour: 'Heures',
absence: 'Absence',
employee: 'Employé',
contract_suspension: 'Suspension',
rtt_payment: 'RTT',
fractioned_days: 'Fract.',
paid_leave_days: 'Congés payés',
}
return map[type] ?? type
}
const entityTypeLabel = (type: string): string => ({
work_hour: 'Heures', absence: 'Absence', employee: 'Employé', contract_suspension: 'Suspension',
rtt_payment: 'RTT', fractioned_days: 'Fract.', paid_leave_days: 'Congés payés', week_comment: 'Commentaire',
}[type] ?? type)
onMounted(async () => {
employees.value = await listEmployees()
await loadLogs()
})
onMounted(() => { list.init() })
</script>
+102 -28
View File
@@ -109,11 +109,11 @@ import type {HalfDay} from '~/services/dto/half-day'
import {HALF_DAYS} from '~/services/dto/half-day'
import {listEmployees, updateEmployeeOrder} from '~/services/employees'
import {listAbsenceTypes} from '~/services/absence-types'
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
import {createAbsence, deleteAbsence, listAbsences} from '~/services/absences'
import {listFormationsByDateRange} from '~/services/formations'
import type {Formation} from '~/services/dto/formation'
import {listPublicHolidays} from '~/services/public-holidays'
import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date'
import {formatYmdToFr, getDaysInMonth, normalizeDate, parseYmd, shiftYmd, toYmd} from '~/utils/date'
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
@@ -649,9 +649,68 @@ const handleSubmit = async () => {
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
return
}
if (editingAbsence.value) {
// Modification d'une plage : une absence = une ligne par jour, sans lien en BDD.
// On remplace donc tout le bloc contigu (même type) partant du jour cliqué par la
// nouvelle plage : suppression de l'ancien bloc + recréation. Évite les jours
// fantômes (raccourcissement) et les doublons (l'ancien PATCH ne nettoyait rien).
const originalEmployeeId = editingAbsence.value.employee.id
const newEmployeeId = Number(form.employeeId)
const originalTypeId = editingAbsence.value.type.id
const clickedDate = normalizeDate(editingAbsence.value.startDate)
// Bloc contigu (vers l'avant) depuis le jour cliqué, même employé + même type d'origine.
// On ne touche jamais aux jours antérieurs au jour cliqué.
const sameLeaveDays = new Set(
absences.value
.filter((absence) => absence.employee?.id === originalEmployeeId && absence.type?.id === originalTypeId)
.map((absence) => normalizeDate(absence.startDate))
)
const blockDates = new Set<string>()
let cursor: string | null = clickedDate
while (cursor && sameLeaveDays.has(cursor)) {
blockDates.add(cursor)
cursor = shiftYmd(cursor, 1)
}
// À supprimer : l'ancien bloc + toute absence recouverte par la nouvelle plage.
const toReplace = absences.value.filter((absence) => {
const day = normalizeDate(absence.startDate)
const inBlock = absence.employee?.id === originalEmployeeId && blockDates.has(day)
const inNewRange = absence.employee?.id === newEmployeeId && day >= start && day <= end
return inBlock || inNewRange
})
// Confirmation uniquement si on écrase une absence d'un AUTRE type (vrai chevauchement).
const replacesForeign = toReplace.some((absence) => absence.type?.id !== originalTypeId)
if (replacesForeign) {
const confirmReplace = window.confirm(
"Cette absence chevauche une autre. Voulez-vous la remplacer ?"
)
if (!confirmReplace) return
}
for (const absence of toReplace) {
await deleteAbsence(absence.id)
}
await createAbsence({
employeeId: newEmployeeId,
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: form.comment
})
closeDrawer()
await loadAbsences()
return
}
// Création : détection de chevauchement (précision demi-journée) puis remplacement.
const overlaps = absences.value.filter((absence) => {
if (absence.employee?.id !== Number(form.employeeId)) return false
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
const aStart = normalizeDate(absence.startDate)
const aEnd = normalizeDate(absence.endDate)
if (start > aEnd || end < aStart) return false
@@ -701,28 +760,15 @@ const handleSubmit = async () => {
}
}
if (editingAbsence.value) {
await updateAbsence({
id: editingAbsence.value.id,
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: form.comment
})
} else {
await createAbsence({
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: form.comment
})
}
await createAbsence({
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: form.comment
})
closeDrawer()
await loadAbsences()
@@ -731,14 +777,42 @@ const handleSubmit = async () => {
}
}
// Suppression de l'absence en cours d'édition.
// Suppression: efface toutes les absences de l'employé comprises dans la plage
// sélectionnée (date début date fin du drawer). Comme une absence = une ligne
// par jour en BDD, on supprime chaque jour existant de la plage ; les jours sans
// absence (ex. une date hors plage réelle) sont naturellement ignorés.
const handleDelete = async () => {
if (!editingAbsence.value) return
const confirmDelete = window.confirm('Supprimer cette absence ?')
const employeeId = editingAbsence.value.employee.id
const rangeStart = normalizeDate(form.startDate)
const rangeEnd = normalizeDate(form.endDate)
if (rangeStart > rangeEnd) {
window.alert("La date de fin ne peut pas etre avant la date de debut.")
return
}
const toDelete = absences.value.filter((absence) => {
if (absence.employee?.id !== employeeId) return false
const day = normalizeDate(absence.startDate)
return day >= rangeStart && day <= rangeEnd
})
if (toDelete.length === 0) {
closeDrawer()
return
}
const confirmDelete = window.confirm(
toDelete.length === 1
? 'Supprimer cette absence ?'
: `Supprimer ${toDelete.length} jours de congé du ${formatYmdToFr(rangeStart)} au ${formatYmdToFr(rangeEnd)} ?`
)
if (!confirmDelete) return
await deleteAbsence(editingAbsence.value.id)
for (const absence of toDelete) {
await deleteAbsence(absence.id)
}
closeDrawer()
await loadAbsences()
}
+16 -10
View File
@@ -1,11 +1,16 @@
import type { AuditLog } from './dto/audit-log'
export type AuditLogFilters = {
employeeId?: number
employee?: string
from?: string
to?: string
entityType?: string
entityType?: string[]
action?: string[]
username?: string
ip?: string
device?: string
page?: number
perPage?: number
}
export type AuditLogPage = {
@@ -17,17 +22,18 @@ export type AuditLogPage = {
export const fetchAuditLogs = async (filters: AuditLogFilters = {}): Promise<AuditLogPage> => {
const api = useApi()
const params: Record<string, string> = {}
const params: Record<string, string | string[]> = {}
if (filters.employeeId) params.employeeId = String(filters.employeeId)
if (filters.employee && filters.employee.trim() !== '') params.employee = filters.employee.trim()
if (filters.from) params.from = filters.from
if (filters.to) params.to = filters.to
if (filters.entityType) params.entityType = filters.entityType
if (filters.entityType && filters.entityType.length > 0) params['entityType[]'] = filters.entityType
if (filters.action && filters.action.length > 0) params['action[]'] = filters.action
if (filters.username && filters.username.trim() !== '') params.username = filters.username.trim()
if (filters.ip && filters.ip.trim() !== '') params.ip = filters.ip.trim()
if (filters.device && filters.device.trim() !== '') params.device = filters.device.trim()
if (filters.page) params.page = String(filters.page)
if (filters.perPage) params.perPage = String(filters.perPage)
return api.get<AuditLogPage>(
'/audit-logs',
params,
{ toast: false }
)
return api.get<AuditLogPage>('/audit-logs', params, { toast: false })
}
+4
View File
@@ -8,5 +8,9 @@ export type AuditLog = {
description: string
changes: { old?: Record<string, unknown>; new?: Record<string, unknown> } | null
affectedDate: string | null
ipAddress: string | null
userAgent: string | null
deviceLabel: string | null
deviceId: string | null
createdAt: string
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260624120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add forensic context columns (ip, user agent, device label, device id) to audit_logs';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE audit_logs ADD ip_address VARCHAR(45) DEFAULT NULL');
$this->addSql('ALTER TABLE audit_logs ADD user_agent TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE audit_logs ADD device_label VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE audit_logs ADD device_id VARCHAR(64) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE audit_logs DROP COLUMN ip_address');
$this->addSql('ALTER TABLE audit_logs DROP COLUMN user_agent');
$this->addSql('ALTER TABLE audit_logs DROP COLUMN device_label');
$this->addSql('ALTER TABLE audit_logs DROP COLUMN device_id');
}
}
+7
View File
@@ -16,9 +16,16 @@ use App\State\AuditLogProvider;
provider: AuditLogProvider::class,
parameters: [
new QueryParameter(key: 'employeeId'),
new QueryParameter(key: 'employee'),
new QueryParameter(key: 'from'),
new QueryParameter(key: 'to'),
new QueryParameter(key: 'entityType'),
new QueryParameter(key: 'action'),
new QueryParameter(key: 'username'),
new QueryParameter(key: 'ip'),
new QueryParameter(key: 'device'),
new QueryParameter(key: 'page'),
new QueryParameter(key: 'perPage'),
],
security: "is_granted('ROLE_SUPER_ADMIN')"
),
+60
View File
@@ -46,6 +46,18 @@ class AuditLog
#[ORM\Column(type: 'date_immutable', nullable: true)]
private ?DateTimeImmutable $affectedDate = null;
#[ORM\Column(type: 'string', length: 45, nullable: true)]
private ?string $ipAddress = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $userAgent = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $deviceLabel = null;
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $deviceId = null;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
@@ -155,6 +167,54 @@ class AuditLog
return $this;
}
public function getIpAddress(): ?string
{
return $this->ipAddress;
}
public function setIpAddress(?string $ipAddress): self
{
$this->ipAddress = $ipAddress;
return $this;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function setUserAgent(?string $userAgent): self
{
$this->userAgent = $userAgent;
return $this;
}
public function getDeviceLabel(): ?string
{
return $this->deviceLabel;
}
public function setDeviceLabel(?string $deviceLabel): self
{
$this->deviceLabel = $deviceLabel;
return $this;
}
public function getDeviceId(): ?string
{
return $this->deviceId;
}
public function setDeviceId(?string $deviceId): self
{
$this->deviceId = $deviceId;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
+68 -57
View File
@@ -5,28 +5,32 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\AuditLog;
use App\Repository\Contract\AuditLogReadRepositoryInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AuditLog>
*/
final class AuditLogRepository extends ServiceEntityRepository
final class AuditLogRepository extends ServiceEntityRepository implements AuditLogReadRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AuditLog::class);
}
/**
* @return list<AuditLog>
*/
public function findByFilters(
?int $employeeId = null,
?DateTimeImmutable $from = null,
?DateTimeImmutable $to = null,
?string $entityType = null,
?array $entityTypes = null,
?array $actions = null,
?string $username = null,
?string $ip = null,
?string $device = null,
?string $employeeName = null,
int $limit = 50,
int $offset = 0,
): array {
@@ -35,30 +39,7 @@ final class AuditLogRepository extends ServiceEntityRepository
->setMaxResults($limit)
->setFirstResult($offset)
;
if (null !== $employeeId) {
$qb->andWhere('a.employee = :employeeId')
->setParameter('employeeId', $employeeId)
;
}
if (null !== $from) {
$qb->andWhere('a.affectedDate >= :from')
->setParameter('from', $from)
;
}
if (null !== $to) {
$qb->andWhere('a.affectedDate <= :to')
->setParameter('to', $to)
;
}
if (null !== $entityType) {
$qb->andWhere('a.entityType = :entityType')
->setParameter('entityType', $entityType)
;
}
$this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device, $employeeName);
return $qb->getQuery()->getResult();
}
@@ -67,36 +48,66 @@ final class AuditLogRepository extends ServiceEntityRepository
?int $employeeId = null,
?DateTimeImmutable $from = null,
?DateTimeImmutable $to = null,
?string $entityType = null,
?array $entityTypes = null,
?array $actions = null,
?string $username = null,
?string $ip = null,
?string $device = null,
?string $employeeName = null,
): int {
$qb = $this->createQueryBuilder('a')
->select('COUNT(a.id)')
;
if (null !== $employeeId) {
$qb->andWhere('a.employee = :employeeId')
->setParameter('employeeId', $employeeId)
;
}
if (null !== $from) {
$qb->andWhere('a.affectedDate >= :from')
->setParameter('from', $from)
;
}
if (null !== $to) {
$qb->andWhere('a.affectedDate <= :to')
->setParameter('to', $to)
;
}
if (null !== $entityType) {
$qb->andWhere('a.entityType = :entityType')
->setParameter('entityType', $entityType)
;
}
$qb = $this->createQueryBuilder('a')->select('COUNT(a.id)');
$this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device, $employeeName);
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* @param null|list<string> $entityTypes
* @param null|list<string> $actions
*/
private function applyFilters(
QueryBuilder $qb,
?int $employeeId,
?DateTimeImmutable $from,
?DateTimeImmutable $to,
?array $entityTypes,
?array $actions,
?string $username,
?string $ip,
?string $device,
?string $employeeName = null,
): void {
if (null !== $employeeId) {
$qb->andWhere('a.employee = :employeeId')->setParameter('employeeId', $employeeId);
}
if (null !== $employeeName && '' !== $employeeName) {
$qb->join('a.employee', 'e')
->andWhere('LOWER(e.lastName) LIKE :employeeName OR LOWER(e.firstName) LIKE :employeeName')
->setParameter('employeeName', '%'.mb_strtolower($employeeName).'%')
;
}
if (null !== $from) {
$qb->andWhere('a.affectedDate >= :from')->setParameter('from', $from);
}
if (null !== $to) {
$qb->andWhere('a.affectedDate <= :to')->setParameter('to', $to);
}
if (null !== $entityTypes && [] !== $entityTypes) {
$qb->andWhere('a.entityType IN (:entityTypes)')->setParameter('entityTypes', $entityTypes);
}
if (null !== $actions && [] !== $actions) {
$qb->andWhere('a.action IN (:actions)')->setParameter('actions', $actions);
}
if (null !== $username && '' !== $username) {
$qb->andWhere('LOWER(a.username) LIKE :username')->setParameter('username', '%'.mb_strtolower($username).'%');
}
if (null !== $ip && '' !== $ip) {
$qb->andWhere('LOWER(a.ipAddress) LIKE :ip')->setParameter('ip', '%'.mb_strtolower($ip).'%');
}
if (null !== $device && '' !== $device) {
$qb->andWhere('(LOWER(a.deviceLabel) LIKE :device OR LOWER(a.deviceId) LIKE :device)')
->setParameter('device', '%'.mb_strtolower($device).'%')
;
}
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Repository\Contract;
use App\Entity\AuditLog;
use DateTimeImmutable;
interface AuditLogReadRepositoryInterface
{
/**
* @param null|list<string> $entityTypes
* @param null|list<string> $actions
*
* @return list<AuditLog>
*/
public function findByFilters(
?int $employeeId = null,
?DateTimeImmutable $from = null,
?DateTimeImmutable $to = null,
?array $entityTypes = null,
?array $actions = null,
?string $username = null,
?string $ip = null,
?string $device = null,
?string $employeeName = null,
int $limit = 50,
int $offset = 0,
): array;
/**
* @param null|list<string> $entityTypes
* @param null|list<string> $actions
*/
public function countByFilters(
?int $employeeId = null,
?DateTimeImmutable $from = null,
?DateTimeImmutable $to = null,
?array $entityTypes = null,
?array $actions = null,
?string $username = null,
?string $ip = null,
?string $device = null,
?string $employeeName = null,
): int;
}
+26
View File
@@ -10,12 +10,15 @@ use App\Entity\User;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
readonly class AuditLogger
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private RequestStack $requestStack,
private UserAgentParser $userAgentParser,
) {}
public function log(
@@ -30,6 +33,25 @@ readonly class AuditLogger
$user = $this->security->getUser();
$username = $user instanceof User ? $user->getUsername() : 'system';
$request = $this->requestStack->getCurrentRequest();
$ipAddress = null;
$userAgent = null;
$deviceId = null;
if (null !== $request) {
$ipAddress = $request->getClientIp();
$userAgent = $request->headers->get('User-Agent');
$deviceId = $request->headers->get('X-Device-Id');
// The device id comes from an untrusted client header; cap it to the column width.
if (null !== $deviceId) {
$deviceId = mb_substr($deviceId, 0, 64);
}
// The user agent comes from an untrusted client header; cap it to prevent storage bloat.
if (null !== $userAgent) {
$userAgent = mb_substr($userAgent, 0, 1024);
}
}
$auditLog = new AuditLog();
$auditLog
->setEmployee($employee)
@@ -40,6 +62,10 @@ readonly class AuditLogger
->setDescription($description)
->setChanges($changes)
->setAffectedDate($affectedDate)
->setIpAddress($ipAddress)
->setUserAgent($userAgent)
->setDeviceLabel($this->userAgentParser->parse($userAgent))
->setDeviceId($deviceId)
;
$this->entityManager->persist($auditLog);
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Derives a short, human-readable label ("Type · OS · Browser") from a raw
* User-Agent string, used to add forensic context to audit log entries.
* Heuristic on purpose enough to tell a phone from a desktop and identify
* OS/browser families on shared accounts.
*/
class UserAgentParser
{
public function parse(?string $userAgent): ?string
{
if (null === $userAgent) {
return null;
}
$ua = trim($userAgent);
if ('' === $ua) {
return null;
}
return implode(' · ', [
$this->detectType($ua),
$this->detectOs($ua),
$this->detectBrowser($ua),
]);
}
private function detectType(string $ua): string
{
if (1 === preg_match('/iPad|Tablet/i', $ua)) {
return 'Tablette';
}
if (1 === preg_match('/Mobile|Android|iPhone|iPod/i', $ua)) {
return 'Mobile';
}
return 'Ordinateur';
}
private function detectOs(string $ua): string
{
// Order matters: iOS before macOS (iOS UAs contain "Mac OS X"),
// Android before Linux (Android UAs contain "Linux").
return match (true) {
1 === preg_match('/iPhone|iPad|iPod/i', $ua) => 'iOS',
1 === preg_match('/Android/i', $ua) => 'Android',
1 === preg_match('/Windows/i', $ua) => 'Windows',
1 === preg_match('/Mac OS X|Macintosh/i', $ua) => 'macOS',
1 === preg_match('/Linux/i', $ua) => 'Linux',
default => 'Autre',
};
}
private function detectBrowser(string $ua): string
{
// Order matters: Edge/Opera contain "Chrome" and "Safari";
// Chrome contains "Safari". Match the most specific first.
return match (true) {
1 === preg_match('/Edg/i', $ua) => 'Edge',
1 === preg_match('/OPR|Opera/i', $ua) => 'Opera',
1 === preg_match('/Firefox|FxiOS/i', $ua) => 'Firefox',
1 === preg_match('/Chrome|CriOS/i', $ua) => 'Chrome',
1 === preg_match('/Safari/i', $ua) => 'Safari',
default => 'Autre',
};
}
}
+53 -15
View File
@@ -6,7 +6,7 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\AuditLogRepository;
use App\Repository\Contract\AuditLogReadRepositoryInterface;
use DateTimeImmutable;
use DateTimeZone;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -14,11 +14,12 @@ use Symfony\Component\HttpFoundation\RequestStack;
class AuditLogProvider implements ProviderInterface
{
private const PER_PAGE = 50;
private const DEFAULT_PER_PAGE = 10;
private const ALLOWED_PER_PAGE = [10, 25, 50, 100];
public function __construct(
private readonly RequestStack $requestStack,
private readonly AuditLogRepository $auditLogRepository,
private readonly AuditLogReadRepositoryInterface $auditLogRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
@@ -28,20 +29,33 @@ class AuditLogProvider implements ProviderInterface
return new JsonResponse(['items' => [], 'total' => 0]);
}
$employeeId = $request->query->get('employeeId');
$from = $request->query->get('from');
$to = $request->query->get('to');
$entityType = $request->query->get('entityType');
$page = max(1, (int) $request->query->get('page', '1'));
$query = $request->query;
$all = $query->all();
$employeeId = $query->get('employeeId');
$from = $query->get('from');
$to = $query->get('to');
$page = max(1, (int) $query->get('page', '1'));
$perPage = (int) $query->get('perPage', (string) self::DEFAULT_PER_PAGE);
if (!in_array($perPage, self::ALLOWED_PER_PAGE, true)) {
$perPage = self::DEFAULT_PER_PAGE;
}
$entityTypes = $this->normalizeList($all['entityType'] ?? null);
$actions = $this->normalizeList($all['action'] ?? null);
$username = $this->normalizeString($query->get('username'));
$ip = $this->normalizeString($query->get('ip'));
$device = $this->normalizeString($query->get('device'));
$employee = $this->normalizeString($query->get('employee'));
$empId = $employeeId ? (int) $employeeId : null;
$fromDt = $from ? new DateTimeImmutable($from) : null;
$toDt = $to ? new DateTimeImmutable($to) : null;
$type = $entityType ?: null;
$offset = ($page - 1) * self::PER_PAGE;
$fromDt = $from ? new DateTimeImmutable((string) $from) : null;
$toDt = $to ? new DateTimeImmutable((string) $to) : null;
$offset = ($page - 1) * $perPage;
$total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $type);
$logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $type, self::PER_PAGE, $offset);
$total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $employee);
$logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $employee, $perPage, $offset);
$items = [];
foreach ($logs as $log) {
@@ -60,6 +74,10 @@ class AuditLogProvider implements ProviderInterface
'description' => $log->getDescription(),
'changes' => $log->getChanges(),
'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
'ipAddress' => $log->getIpAddress(),
'userAgent' => $log->getUserAgent(),
'deviceLabel' => $log->getDeviceLabel(),
'deviceId' => $log->getDeviceId(),
'createdAt' => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'),
];
}
@@ -68,7 +86,27 @@ class AuditLogProvider implements ProviderInterface
'items' => $items,
'total' => $total,
'page' => $page,
'perPage' => self::PER_PAGE,
'perPage' => $perPage,
]);
}
/**
* @return null|list<string>
*/
private function normalizeList(mixed $value): ?array
{
$list = array_values(array_filter(
(array) ($value ?? []),
static fn ($v): bool => is_string($v) && '' !== trim($v),
));
return [] === $list ? null : $list;
}
private function normalizeString(mixed $value): ?string
{
$trimmed = trim((string) ($value ?? ''));
return '' === $trimmed ? null : $trimmed;
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\AuditLog;
use App\Service\AuditLogger;
use App\Service\UserAgentParser;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* @internal
*/
final class AuditLoggerTest extends TestCase
{
public function testCapturesRequestContext(): void
{
$persisted = null;
$em = $this->createStub(EntityManagerInterface::class);
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
$persisted = $entity;
});
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn(null); // -> username "system"
$request = Request::create('/api/work_hours', 'POST');
$request->server->set('REMOTE_ADDR', '203.0.113.7');
$request->headers->set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
$request->headers->set('X-Device-Id', 'device-abc');
$stack = new RequestStack();
$stack->push($request);
$logger = new AuditLogger($em, $security, $stack, new UserAgentParser());
$logger->log(null, 'create', 'work_hour', 1, 'desc');
self::assertInstanceOf(AuditLog::class, $persisted);
self::assertSame('203.0.113.7', $persisted->getIpAddress());
self::assertSame('device-abc', $persisted->getDeviceId());
self::assertSame('Ordinateur · Windows · Chrome', $persisted->getDeviceLabel());
self::assertNotNull($persisted->getUserAgent());
}
public function testTruncatesOverlongDeviceId(): void
{
$persisted = null;
$em = $this->createStub(EntityManagerInterface::class);
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
$persisted = $entity;
});
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn(null);
$request = Request::create('/api/work_hours', 'POST');
$request->headers->set('X-Device-Id', str_repeat('x', 200));
$stack = new RequestStack();
$stack->push($request);
$logger = new AuditLogger($em, $security, $stack, new UserAgentParser());
$logger->log(null, 'create', 'work_hour', 1, 'desc');
self::assertSame(64, mb_strlen((string) $persisted->getDeviceId()));
}
public function testNoRequestLeavesContextNull(): void
{
$persisted = null;
$em = $this->createStub(EntityManagerInterface::class);
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
$persisted = $entity;
});
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn(null);
$logger = new AuditLogger($em, $security, new RequestStack(), new UserAgentParser());
$logger->log(null, 'create', 'work_hour', 1, 'desc');
self::assertNull($persisted->getIpAddress());
self::assertNull($persisted->getUserAgent());
self::assertNull($persisted->getDeviceLabel());
self::assertNull($persisted->getDeviceId());
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\UserAgentParser;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class UserAgentParserTest extends TestCase
{
private UserAgentParser $parser;
protected function setUp(): void
{
$this->parser = new UserAgentParser();
}
public function testNullAndEmptyReturnNull(): void
{
self::assertNull($this->parser->parse(null));
self::assertNull($this->parser->parse(''));
self::assertNull($this->parser->parse(' '));
}
public function testChromeOnWindows(): void
{
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
self::assertSame('Ordinateur · Windows · Chrome', $this->parser->parse($ua));
}
public function testEdgeBeatsChrome(): void
{
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0';
self::assertSame('Ordinateur · Windows · Edge', $this->parser->parse($ua));
}
public function testSafariOnIphoneIsMobileIos(): void
{
$ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
self::assertSame('Mobile · iOS · Safari', $this->parser->parse($ua));
}
public function testChromeOnAndroid(): void
{
$ua = 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
self::assertSame('Mobile · Android · Chrome', $this->parser->parse($ua));
}
public function testFirefoxOnLinux(): void
{
$ua = 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0';
self::assertSame('Ordinateur · Linux · Firefox', $this->parser->parse($ua));
}
public function testSafariOnMac(): void
{
$ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
self::assertSame('Ordinateur · macOS · Safari', $this->parser->parse($ua));
}
public function testIpadIsTablet(): void
{
$ua = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
self::assertSame('Tablette · iOS · Safari', $this->parser->parse($ua));
}
public function testUnknownUaFallsBack(): void
{
self::assertSame('Ordinateur · Autre · Autre', $this->parser->parse('SomeRandomBot/1.0'));
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Operation;
use App\Entity\AuditLog;
use App\Repository\Contract\AuditLogReadRepositoryInterface;
use App\State\AuditLogProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* @internal
*/
final class AuditLogProviderTest extends TestCase
{
public function testExposesForensicFields(): void
{
$log = new AuditLog()
->setUsername('usine')
->setAction('create')
->setEntityType('work_hour')
->setDescription('desc')
->setIpAddress('203.0.113.7')
->setUserAgent('UA-string')
->setDeviceLabel('Mobile · Android · Chrome')
->setDeviceId('device-abc')
;
$response = $this->provideWith($this->spyRepository([$log], 1), []);
$item = json_decode((string) $response->getContent(), true)['items'][0];
self::assertSame('203.0.113.7', $item['ipAddress']);
self::assertSame('UA-string', $item['userAgent']);
self::assertSame('Mobile · Android · Chrome', $item['deviceLabel']);
self::assertSame('device-abc', $item['deviceId']);
}
public function testPassesNewFiltersToRepository(): void
{
$repo = $this->spyRepository();
$this->provideWith($repo, [
'employeeId' => '5',
'employee' => 'dupont',
'username' => 'usine',
'ip' => '10.0.',
'device' => 'android',
'entityType' => ['work_hour', 'absence'],
'action' => ['create'],
'perPage' => '25',
'page' => '2',
]);
self::assertSame(5, $repo->findArgs['employeeId']);
self::assertSame('dupont', $repo->findArgs['employeeName']);
self::assertSame('usine', $repo->findArgs['username']);
self::assertSame('10.0.', $repo->findArgs['ip']);
self::assertSame('android', $repo->findArgs['device']);
self::assertSame(['work_hour', 'absence'], $repo->findArgs['entityTypes']);
self::assertSame(['create'], $repo->findArgs['actions']);
self::assertSame(25, $repo->findArgs['limit']);
self::assertSame(25, $repo->findArgs['offset']); // page 2, perPage 25 -> offset 25
}
public function testBlankFiltersBecomeNull(): void
{
$repo = $this->spyRepository();
$this->provideWith($repo, ['username' => ' ', 'ip' => '', 'device' => '']);
self::assertNull($repo->findArgs['username']);
self::assertNull($repo->findArgs['ip']);
self::assertNull($repo->findArgs['device']);
self::assertNull($repo->findArgs['entityTypes']);
self::assertNull($repo->findArgs['actions']);
}
public function testPerPageOutOfRangeFallsBackToDefault(): void
{
$repo = $this->spyRepository();
$response = $this->provideWith($repo, ['perPage' => '999']);
self::assertSame(10, $repo->findArgs['limit']);
self::assertSame(10, json_decode((string) $response->getContent(), true)['perPage']);
}
public function testDefaultPerPageIs10(): void
{
$repo = $this->spyRepository();
$response = $this->provideWith($repo, []);
self::assertSame(10, $repo->findArgs['limit']);
self::assertSame(10, json_decode((string) $response->getContent(), true)['perPage']);
}
private function spyRepository(array $items = [], int $count = 0): AuditLogReadRepositoryInterface
{
return new class($items, $count) implements AuditLogReadRepositoryInterface {
public array $findArgs = [];
public array $countArgs = [];
public function __construct(private array $items, private int $count) {}
public function findByFilters(?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?array $entityTypes = null, ?array $actions = null, ?string $username = null, ?string $ip = null, ?string $device = null, ?string $employeeName = null, int $limit = 50, int $offset = 0): array
{
$this->findArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device', 'employeeName', 'limit', 'offset');
return $this->items;
}
public function countByFilters(?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?array $entityTypes = null, ?array $actions = null, ?string $username = null, ?string $ip = null, ?string $device = null, ?string $employeeName = null): int
{
$this->countArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device', 'employeeName');
return $this->count;
}
};
}
private function provideWith(AuditLogReadRepositoryInterface $repo, array $query): JsonResponse
{
$stack = new RequestStack();
$stack->push(Request::create('/api/audit-logs', 'GET', $query));
$provider = new AuditLogProvider($stack, $repo);
return $provider->provide($this->createStub(Operation::class));
}
}