# Tickets correctifs — PR `feat/audit-log` (4e passe)

> Issus de la review du 2026-04-23 (`REVIEW.md`).
> Chaque ticket est autonome : pourquoi, quoi faire, fichiers concernes.
> Commence par les P0. Un commit par ticket (message `fix(T-XXX) : description`).

---

## P0 — Urgents (securite avant mise en prod)

### T-001 — Fermer `/api/docs` en production

**Pourquoi :** la doc Swagger expose aujourd'hui publiquement la liste de tous les endpoints, les permissions RBAC demandees, les filtres disponibles, les DTOs. Pour un outil interne c'est inutile et ca donne a un attaquant la carte complete de la surface d'attaque sans meme avoir a se connecter.

**A faire :**

1. Ouvrir `config/packages/security.yaml`.
2. Supprimer la ligne qui ouvre `/api/docs` :

```yaml
# AVANT
access_control:
    - { path: ^/login_check, roles: PUBLIC_ACCESS }
    - { path: ^/api/docs, roles: PUBLIC_ACCESS }
    - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
    - { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] }
    - { path: ^/api/sidebar, roles: PUBLIC_ACCESS, methods: [ GET ] }
    - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

# APRES (on supprime simplement la 2e ligne)
access_control:
    - { path: ^/login_check, roles: PUBLIC_ACCESS }
    - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
    - { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] }
    - { path: ^/api/sidebar, roles: PUBLIC_ACCESS, methods: [ GET ] }
    - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
```

Comme `/api/docs` tombe desormais dans le dernier pattern (`^/api`), il faudra etre authentifie pour le voir. Les devs continueront de l'utiliser apres login — les attaquants non.

3. Recharger : `make cache-clear` puis `make restart`.
4. Tester : `curl -i https://starseed.malio-dev.fr/api/docs` doit retourner `401 Unauthorized` (avant : `200`).

**Fichiers :** `config/packages/security.yaml`

---

### T-002 — Ajouter les en-tetes de securite HTTP de base en prod

**Pourquoi :** sans `X-Frame-Options`, quelqu'un peut integrer Starseed dans une iframe sur un site tiers et faire du clickjacking (faire croire a l'utilisateur qu'il clique sur un bouton anodin alors qu'il valide une action dans Starseed). Sans `X-Content-Type-Options: nosniff`, un navigateur peut deviner le type MIME et executer un fichier qui n'aurait pas du l'etre. Ce sont 3 lignes de config Nginx pour proteger l'application.

**A faire :**

1. Ouvrir `infra/prod/nginx-proxy.conf` (c'est le proxy expose au public).
2. Ajouter juste apres `server_name starseed.malio-dev.fr;` :

```nginx
# En-tetes de securite applicables a toutes les reponses
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
```

Explication :
- `X-Frame-Options: DENY` : personne ne peut mettre Starseed dans une iframe.
- `X-Content-Type-Options: nosniff` : le navigateur ne devine pas les types MIME, il fait confiance a ce que le serveur annonce.
- `Referrer-Policy: strict-origin-when-cross-origin` : limite ce que Starseed envoie comme Referer a des sites externes (evite de leaker `/admin/users/42` a un site tiers).
- `always` : envoyer ces en-tetes meme sur les reponses d'erreur (4xx/5xx).

3. Recharger Nginx : `docker restart nginx-starseed` (ou celui qui fait office de proxy public).
4. Verifier : `curl -I https://starseed.malio-dev.fr/` doit afficher ces trois en-tetes.

**Note :** si un reverse proxy externe (Traefik, Cloudflare) ajoute deja ces en-tetes, les poser ici ne fait que dupliquer, c'est sans risque (meme valeur).

**Fichiers :** `infra/prod/nginx-proxy.conf`

---

### T-003 — Interdire l'indexation par les moteurs de recherche

**Pourquoi :** `frontend/public/robots.txt` autorise actuellement Google et compagnie a indexer toutes les pages. Pour un CRM interne, pas besoin que les URLs de pages admin, de fiches clients, etc. remontent dans les resultats de recherche. Meme si le contenu est protege par login, la simple enumeration des URLs publiques est un leak.

**A faire :**

1. Ouvrir `frontend/public/robots.txt`.
2. Remplacer le contenu par :

```
User-Agent: *
Disallow: /
```

**Fichiers :** `frontend/public/robots.txt`

---

## P1 — Importants (bugs silencieux + qualite)

### T-004 — Valider/typer le filtre `performed_at` sur le journal d'audit

**Pourquoi :** quand un utilisateur envoie `?performed_at[after]=pasunedate`, PostgreSQL n'arrive pas a caster la chaine en timestamp et leve une erreur. L'API retourne un 500 (erreur serveur) au lieu du 400 propre (erreur client) qu'elle devrait. En plus, les logs d'erreur se remplissent inutilement.

**A faire :**

1. Ouvrir `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`.
2. En haut du fichier, ajouter les imports suivants si absents :

```php
use Doctrine\DBAL\Types\Types;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
```

3. Dans `applyFilters()`, remplacer les blocs `performed_at_after` et `performed_at_before` :

```php
// AVANT
if (isset($filters['performed_at_after'])) {
    $qb->andWhere('performed_at >= :performed_at_after')
       ->setParameter('performed_at_after', $filters['performed_at_after']);
}
if (isset($filters['performed_at_before'])) {
    $qb->andWhere('performed_at <= :performed_at_before')
       ->setParameter('performed_at_before', $filters['performed_at_before']);
}
```

```php
// APRES
if (isset($filters['performed_at_after'])) {
    try {
        $after = new \DateTimeImmutable($filters['performed_at_after']);
    } catch (\Throwable) {
        throw new BadRequestHttpException('performed_at[after] doit etre une date ISO 8601 valide.');
    }
    $qb->andWhere('performed_at >= :performed_at_after')
       ->setParameter('performed_at_after', $after, Types::DATETIMETZ_IMMUTABLE);
}
if (isset($filters['performed_at_before'])) {
    try {
        $before = new \DateTimeImmutable($filters['performed_at_before']);
    } catch (\Throwable) {
        throw new BadRequestHttpException('performed_at[before] doit etre une date ISO 8601 valide.');
    }
    $qb->andWhere('performed_at <= :performed_at_before')
       ->setParameter('performed_at_before', $before, Types::DATETIMETZ_IMMUTABLE);
}
```

4. Tester : `curl -i -b cookie.txt 'http://localhost:8083/api/audit-logs?performed_at%5Bafter%5D=pasunedate'` doit retourner `400 Bad Request`, plus `500`.
5. Tester aussi avec une date valide : `curl ... 'performed_at%5Bafter%5D=2026-01-01T00:00:00Z'` doit retourner `200` avec les bons resultats.

**Fichiers :** `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`

---

### T-005 — Ajouter la clause `ESCAPE` sur le filtre ILIKE `performed_by`

**Pourquoi :** le filtre "recherche contient" sur le nom d'utilisateur utilise `ILIKE`. Quand un user cherche `admin_backup`, le `_` est un caractere special LIKE qui matche "n'importe quel caractere". Le code essaie d'echapper avec `\_` mais **il faut dire a PostgreSQL que `\` est l'echappement** via la clause `ESCAPE`. Sans ca, sur certaines configs PG, `\_` devient deux caracteres distincts et l'echappement est cassé.

Concretement : chercher `admin_backup` peut faire remonter aussi `adminXbackup`, `admin1backup`, etc.

**A faire :**

1. Ouvrir `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`.
2. Dans `applyFilters()`, modifier le bloc `performed_by` :

```php
// AVANT (lignes ~171-180)
if (isset($filters['performed_by'])) {
    // Recherche contains insensible a la casse pour matcher "adm" → "admin".
    // On echappe `%`, `_` et `\` saisis par l'utilisateur pour qu'ils soient
    // interpretes comme caracteres litteraux (sinon `%` matche tout, `_`
    // matche n'importe quel caractere). Pas de clause ESCAPE : `\` est
    // deja le caractere d'echappement LIKE par defaut en PostgreSQL.
    $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
    $qb->andWhere('performed_by ILIKE :performed_by')
        ->setParameter('performed_by', '%'.$escaped.'%')
    ;
}
```

```php
// APRES
if (isset($filters['performed_by'])) {
    // Recherche contains insensible a la casse pour matcher "adm" → "admin".
    // On echappe `%`, `_` et `\` pour qu'ils soient traites comme caracteres
    // litteraux. La clause ESCAPE est EXPLICITE : en SQL standard, LIKE n'a
    // pas d'echappement par defaut, il faut le declarer.
    $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
    $qb->andWhere("performed_by ILIKE :performed_by ESCAPE '\\\\'")
        ->setParameter('performed_by', '%'.$escaped.'%')
    ;
}
```

Note : les 4 `\` en PHP donnent 2 `\` dans la string SQL, qui devient 1 `\` apres parsing PostgreSQL. C'est la chaine d'echappement correcte.

3. Tester en psql : creer une entree avec `performed_by = 'admin_backup'`, puis :

```bash
curl -b cookie.txt 'http://localhost:8083/api/audit-logs?performed_by=admin_backup'
# → doit retourner UNIQUEMENT admin_backup, pas adminXbackup ou autres
```

**Fichiers :** `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`

---

### T-006 — Refuser explicitement les appelants non-`User` dans `SiteAwareInjectionProcessor`

**Pourquoi :** le processor qui injecte/valide le site lors de la creation d'une entite site-aware verifie `$user instanceof User`. Si jamais l'appelant n'est pas exactement une instance de cette classe (ex: futur provider d'auth, session systeme), la condition est fausse et **la validation est silencieusement sautee** — l'utilisateur pourrait creer une entite dans un site auquel il n'appartient pas. Aujourd'hui le risque est nul car il n'y a qu'un seul provider, mais le pattern "pas le bon type = on laisse passer" est fragile.

**A faire :**

1. Ouvrir `src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php`.
2. Modifier la partie validation cross-site (ligne 64-75) :

```php
// AVANT
if (!$this->security->isGranted('sites.bypass_scope')) {
    $user         = $this->security->getUser();
    $explicitSite = $data->getSite();
    if ($user instanceof User && $explicitSite instanceof Site && !$user->hasSite($explicitSite)) {
        throw new AccessDeniedHttpException(
            'Le site specifie n\'est pas dans les sites autorises pour cet utilisateur.'
        );
    }
}
```

```php
// APRES
if (!$this->security->isGranted('sites.bypass_scope')) {
    $user = $this->security->getUser();

    // Pas de User authentifie = rejet explicite. On ne laisse pas passer
    // une ecriture site-scoped si on ne peut pas valider le proprietaire.
    if (!$user instanceof User) {
        throw new AccessDeniedHttpException(
            'Utilisateur non reconnu pour la validation de site.',
        );
    }

    $explicitSite = $data->getSite();
    if ($explicitSite instanceof Site && !$user->hasSite($explicitSite)) {
        throw new AccessDeniedHttpException(
            'Le site specifie n\'est pas dans les sites autorises pour cet utilisateur.',
        );
    }
}
```

3. Relancer la suite back : `make test`. Les tests existants sur `SiteAwareInjectionProcessor` doivent encore passer (l'acceptation du happy path est inchangee).

**Fichiers :** `src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php`

---

### T-007 — Entourer `isHandlingUnauthorized` d'un `try/finally`

**Pourquoi :** dans `useApi`, un flag singleton bloque les appels concurrents a `navigateTo('/login')` quand une 401 survient. Si `navigateTo` echoue (middleware qui throw, plugin qui throw, etc.), le flag reste sur `true` indefiniment et **toutes les 401 suivantes sont ignorees silencieusement**. L'utilisateur doit hard-reload pour s'en sortir.

**A faire :**

1. Ouvrir `frontend/shared/composables/useApi.ts`.
2. Dans le handler `onResponseError`, modifier le bloc 401 (lignes 124-131) :

```typescript
// AVANT
if (!isLoginCheck && !isLogout) {
    if (!isHandlingUnauthorized) {
        isHandlingUnauthorized = true
        auth.clearSession()
        await navigateTo('/login')
        isHandlingUnauthorized = false
    }
}
```

```typescript
// APRES
if (!isLoginCheck && !isLogout) {
    if (!isHandlingUnauthorized) {
        isHandlingUnauthorized = true
        try {
            auth.clearSession()
            await navigateTo('/login')
        } finally {
            // Garantir la remise a false meme si navigateTo throw : sinon
            // les 401 suivantes seraient silencieusement ignorees jusqu'a
            // un hard reload.
            isHandlingUnauthorized = false
        }
    }
}
```

3. Pas de test automatise trivial a ecrire (depend de navigateTo) — test manuel : se connecter, laisser le token JWT expirer (ou le supprimer manuellement dans le cookie), declencher deux appels quasi-simultanes et verifier la redirection `/login`.

**Fichiers :** `frontend/shared/composables/useApi.ts`

---

### T-008 — Capper la pagination sur `Permission`, `Role`, `Site` + nettoyer `itemsPerPage: 999`

**Pourquoi :** plusieurs composants (drawers RBAC, page admin sites) appellent l'API avec `itemsPerPage: 999`. Or, `paginationClientItemsPerPage` n'est pas active sur ces ressources, donc le 999 **est silencieusement ignore** et on recoit toujours 30 elements. Aujourd'hui les catalogues sont petits, ca marche par chance. Demain si on passe a 35 permissions, les drawers tronqueront silencieusement — bug invisible.

Parallelement, si on active la pagination client sans plafond, `?itemsPerPage=99999` devient une requete qui sort tout en une fois — DoS bas effort.

La solution la plus simple : desactiver la pagination pour ces catalogues (ils sont exhaustifs par nature) et retirer les `itemsPerPage: 999` cote front.

**A faire :**

1. **Backend** — Ajouter `paginationEnabled: false` sur les `GetCollection` de ces trois ressources.

`src/Module/Core/Domain/Entity/Permission.php` (vers ligne 28-31) :

```php
// APRES
new GetCollection(
    normalizationContext: ['groups' => ['permission:read']],
    security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
    paginationEnabled: false,
),
```

`src/Module/Core/Domain/Entity/Role.php` (trouver le `new GetCollection` et ajouter la meme ligne).

`src/Module/Sites/Domain/Entity/Site.php` (meme chose sur le `GetCollection`) :

```php
new GetCollection(
    normalizationContext: ['groups' => ['site:read']],
    security: "is_granted('sites.view')",
    paginationEnabled: false,
),
```

**Attention** : pour `Site`, la page admin va recuperer TOUS les sites. Tant qu'on reste sous quelques dizaines c'est OK. Si la taille explose plus tard, remettre une pagination avec un plafond explicite (`paginationClientItemsPerPage: true`, `paginationMaximumItemsPerPage: 200`).

2. **Frontend** — Retirer les `itemsPerPage: 999`.

`frontend/modules/core/components/UserRbacDrawer.vue:235-236` :

```typescript
// AVANT
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: true }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: true }),

// APRES
api.get<{ member: Permission[] }>('/permissions', { orphan: false }, { toast: true }),
api.get<{ member: Site[] }>('/sites', {}, { toast: true }),
```

`frontend/modules/core/components/RoleDrawer.vue:149` :

```typescript
// AVANT
const data = await api.get<{ member: Permission[] }>(
    '/permissions',
    { 'orphan': false, itemsPerPage: 999 },
    { toast: true },
)

// APRES
const data = await api.get<{ member: Permission[] }>(
    '/permissions',
    { orphan: false },
    { toast: true },
)
```

`frontend/modules/sites/pages/admin/sites.vue:117` :

```typescript
// AVANT
const data = await api.get<{ member: Site[] }>(
    '/sites',
    { itemsPerPage: 999 },
    { toast: false },
)

// APRES
const data = await api.get<{ member: Site[] }>(
    '/sites',
    {},
    { toast: false },
)
```

3. Tester : ouvrir le `UserRbacDrawer`, verifier que toutes les permissions et tous les sites s'affichent. Idem sur `RoleDrawer` et la page `admin/sites`.

4. Relancer `make test` (PHP) et `make nuxt-test` (front) pour verifier la non-regression.

**Fichiers :**
- `src/Module/Core/Domain/Entity/Permission.php`
- `src/Module/Core/Domain/Entity/Role.php`
- `src/Module/Sites/Domain/Entity/Site.php`
- `frontend/modules/core/components/UserRbacDrawer.vue`
- `frontend/modules/core/components/RoleDrawer.vue`
- `frontend/modules/sites/pages/admin/sites.vue`

---

### T-009 — Proteger `JSON.stringify` dans `AuditLogDetail.vue`

**Pourquoi :** la fonction `formatValue` serialise des valeurs venant de l'API avec `JSON.stringify`. Si un jour la valeur contient un objet avec une reference circulaire (ex: refacto ORM qui serialise une entite), `stringify` leve une erreur qui casse tout le rendu du drawer. Ajouter un try/catch defensif evite de devoir debugger ca en prod.

**A faire :**

1. Ouvrir `frontend/shared/components/audit/AuditLogDetail.vue`.
2. Reperer la fonction `formatValue` (ou equivalent qui appelle `JSON.stringify`).
3. L'entourer d'un try/catch :

```typescript
// AVANT (approximativement)
function formatValue(value: unknown): string {
    if (value === null || value === undefined) return '-'
    if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
    if (typeof value === 'object') return JSON.stringify(value)
    return String(value)
}
```

```typescript
// APRES
function formatValue(value: unknown): string {
    if (value === null || value === undefined) return '-'
    if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
    if (typeof value === 'object') {
        try {
            return JSON.stringify(value)
        } catch {
            return '[valeur non serialisable]'
        }
    }
    return String(value)
}
```

**Fichiers :** `frontend/shared/components/audit/AuditLogDetail.vue`

---

## P2 — Qualite et conventions

### T-010 — Remplacer `<button>` brut par `MalioButton` dans `AuditTimeline`

**Pourquoi :** la regle projet (`.claude/rules/frontend.md`) impose `MalioButton` pour tous les boutons. Un seul bouton HTML brut subsiste dans la PR — celui de "Voir plus" dans la timeline.

**A faire :**

1. Ouvrir `frontend/shared/components/audit/AuditTimeline.vue`, vers ligne 80.
2. Remplacer :

```html
<!-- AVANT -->
<button
    type="button"
    class="mt-3 text-sm text-blue-600 hover:text-blue-800"
    @click="loadMore"
>
    {{ t('audit.timeline.load_more') }}
</button>
```

```html
<!-- APRES -->
<MalioButton
    type="secondary"
    size="sm"
    :label="t('audit.timeline.load_more')"
    class="mt-3"
    @click="loadMore"
/>
```

Si `MalioButton` n'a pas de variant adapte au rendu "lien inline" actuel, garder le `<button>` mais ajouter un TODO explicite :

```html
<!-- TODO(malio-ui) : pas de variant 'link-inline' dispo dans @malio/layer-ui 1.4.2 -->
<button type="button" ...>
```

3. Lancer `make dev-nuxt` et verifier visuellement que le bouton s'affiche et fonctionne.

**Fichiers :** `frontend/shared/components/audit/AuditTimeline.vue`

---

### T-011 — Deplacer `sidebar.core.sites` sous `sidebar.sites.*`

**Pourquoi :** la convention `.claude/rules/naming.md` impose les cles i18n sidebar sous le namespace du module owner (`sidebar.<module>.*`). L'item "Sites" a `'module' => 'sites'` dans `sidebar.php` mais sa cle est `sidebar.core.sites` — incoherent, et si on desactive le module `sites`, il faudra chasser cette cle sous `core`.

**A faire :**

1. Ouvrir `frontend/i18n/locales/fr.json`, trouver la section `sidebar` :

```json
// AVANT
"sidebar": {
    "core": {
        "roles": "Gestion des rôles",
        "users": "Utilisateurs",
        "sites": "Sites",
        "audit_log": "Journal d'audit"
    }
}
```

```json
// APRES
"sidebar": {
    "core": {
        "roles": "Gestion des rôles",
        "users": "Utilisateurs",
        "audit_log": "Journal d'audit"
    },
    "sites": {
        "admin": "Sites"
    }
}
```

2. Ouvrir `config/sidebar.php`, mettre a jour le label de l'item Sites :

```php
// AVANT
[
    'label'      => 'sidebar.core.sites',
    'to'         => '/admin/sites',
    'icon'       => 'mdi:domain',
    'module'     => 'sites',
    'permission' => 'sites.view',
],
```

```php
// APRES
[
    'label'      => 'sidebar.sites.admin',
    'to'         => '/admin/sites',
    'icon'       => 'mdi:domain',
    'module'     => 'sites',
    'permission' => 'sites.view',
],
```

3. `make cache-clear` + relancer `make dev-nuxt`, verifier que la sidebar affiche toujours "Sites".

**Fichiers :**
- `frontend/i18n/locales/fr.json`
- `config/sidebar.php`

---

### T-012 — Rendre `UserPasswordHasherProcessor` et `MeProvider` `final`

**Pourquoi :** convention implicite de la PR : tous les services sont `final readonly`. Deux classes echappent a la regle sans raison. Les garder ouvertes a l'heritage invite un futur bug par sous-classe qui oublierait d'appeler `parent::process()`.

**A faire :**

1. `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserPasswordHasherProcessor.php:16` :

```php
// AVANT
class UserPasswordHasherProcessor implements ProcessorInterface

// APRES
final class UserPasswordHasherProcessor implements ProcessorInterface
```

2. `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php:14` :

```php
// AVANT
class MeProvider implements ProviderInterface

// APRES
final class MeProvider implements ProviderInterface
```

3. Lancer `make test` — doit passer sans regression.

**Fichiers :**
- `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserPasswordHasherProcessor.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/MeProvider.php`

---

### T-013 — Extraire `debounce` dans `frontend/shared/utils/`

**Pourquoi :** la fonction `debounce` est definie inline dans `audit-log.vue`. Si une autre page en ajoute une, on aura du code duplique. `frontend/shared/utils/` existe deja (ex: `color.ts`), c'est l'endroit naturel pour ce genre d'util.

**A faire :**

1. Creer `frontend/shared/utils/debounce.ts` :

```typescript
/**
 * Cree une version debounced de `fn` : chaque appel reset un timer et
 * l'execution reelle attend `delay` ms apres la derniere invocation.
 * Utile pour eviter de spam une recherche a chaque touche.
 */
export function debounce<T extends (...args: never[]) => void>(fn: T, delay: number): T {
    let timer: ReturnType<typeof setTimeout> | null = null
    return ((...args: Parameters<T>) => {
        if (null !== timer) clearTimeout(timer)
        timer = setTimeout(() => fn(...args), delay)
    }) as T
}
```

2. Creer `frontend/shared/utils/__tests__/debounce.test.ts` :

```typescript
import { describe, it, expect, vi } from 'vitest'
import { debounce } from '../debounce'

describe('debounce', () => {
    it('attend delay ms avant d\'appeler fn une seule fois', async () => {
        vi.useFakeTimers()
        const fn = vi.fn()
        const debounced = debounce(fn, 100)

        debounced()
        debounced()
        debounced()
        expect(fn).not.toHaveBeenCalled()

        vi.advanceTimersByTime(100)
        expect(fn).toHaveBeenCalledTimes(1)
        vi.useRealTimers()
    })
})
```

3. Dans `frontend/modules/core/pages/admin/audit-log.vue`, supprimer la definition locale (lignes 306-312) et laisser l'auto-import Nuxt faire son travail (les utils de `frontend/shared/utils/` sont scannes par `nuxt.config.ts` dans `imports.dirs`).

4. `make nuxt-test` doit passer.

**Fichiers :**
- `frontend/shared/utils/debounce.ts` (nouveau)
- `frontend/shared/utils/__tests__/debounce.test.ts` (nouveau)
- `frontend/modules/core/pages/admin/audit-log.vue`

---

### T-014 — Ajouter les paliers `month` et `year` a `relativeDate`

**Pourquoi :** la fonction qui affiche "il y a X minutes/heures/jours/semaines" plafonne a la semaine. Une entree d'audit vieille d'un an apparait comme "il y a 52 semaines" — peu lisible.

**A faire :**

1. Ouvrir `frontend/shared/components/audit/AuditTimeline.vue`, vers ligne 171-182.
2. Etendre la fonction `relativeDate` :

```typescript
// AVANT
function relativeDate(iso: string): string {
    const diffMs = Date.now() - new Date(iso).getTime()
    const diffSec = Math.round(diffMs / 1000)
    const absSec = Math.abs(diffSec)
    const fmt = rtf.value

    if (absSec < 60) return fmt.format(-Math.sign(diffSec) * Math.abs(diffSec), 'second')
    if (absSec < 3600) return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 60), 'minute')
    if (absSec < 86400) return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 3600), 'hour')
    if (absSec < 604800) return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 86400), 'day')
    return fmt.format(-Math.sign(diffSec) * Math.round(absSec / 604800), 'week')
}
```

```typescript
// APRES
function relativeDate(iso: string): string {
    const diffMs = Date.now() - new Date(iso).getTime()
    const diffSec = Math.round(diffMs / 1000)
    const absSec = Math.abs(diffSec)
    const sign = -Math.sign(diffSec)
    const fmt = rtf.value

    if (absSec < 60)       return fmt.format(sign * absSec, 'second')
    if (absSec < 3600)     return fmt.format(sign * Math.round(absSec / 60), 'minute')
    if (absSec < 86400)    return fmt.format(sign * Math.round(absSec / 3600), 'hour')
    if (absSec < 604800)   return fmt.format(sign * Math.round(absSec / 86400), 'day')
    if (absSec < 2592000)  return fmt.format(sign * Math.round(absSec / 604800), 'week')   // < ~30j
    if (absSec < 31536000) return fmt.format(sign * Math.round(absSec / 2592000), 'month') // < ~365j
    return fmt.format(sign * Math.round(absSec / 31536000), 'year')
}
```

3. `make dev-nuxt`, ouvrir un drawer avec un vieux log (ou modifier une date pour tester).

**Fichiers :** `frontend/shared/components/audit/AuditTimeline.vue`

---

### T-015 — Traduire `entityType` dans le drawer de detail d'audit

**Pourquoi :** le titre du drawer affiche `core.User #42` brut. La spec prevoit un lookup i18n pour afficher un nom lisible (ex: "Utilisateur #42"). Les cles sont deja prevues (`audit.entity.user` existe), il suffit d'en ajouter et de les utiliser.

**A faire :**

1. Completer `frontend/i18n/locales/fr.json` avec les cles manquantes :

```json
"audit": {
    ...
    "entity": {
        "core_user":       "Utilisateur",
        "core_role":       "Rôle",
        "core_permission": "Permission",
        "sites_site":      "Site"
    },
    ...
}
```

(remplace l'ancienne cle `audit.entity.user` qui n'etait que pour `User`)

2. Dans `frontend/modules/core/pages/admin/audit-log.vue`, ajouter une fonction helper :

```typescript
const { t, te } = useI18n()

function formatEntityType(type: string): string {
    // Convertit "core.User" -> "core_user" pour matcher la cle i18n
    const key = `audit.entity.${type.toLowerCase().replace(/\./g, '_')}`
    return te(key) ? t(key) : type
}
```

3. Modifier le template du drawer (ligne 138) :

```html
<!-- AVANT -->
<h3 class="text-sm font-medium text-gray-700 mb-2">
    {{ selectedEntry.entityType }} #{{ selectedEntry.entityId }}
</h3>

<!-- APRES -->
<h3 class="text-sm font-medium text-gray-700 mb-2">
    {{ formatEntityType(selectedEntry.entityType) }} #{{ selectedEntry.entityId }}
</h3>
```

4. Tester : ouvrir un drawer sur un audit de User, Role, Site — verifier l'affichage.

**Fichiers :**
- `frontend/i18n/locales/fr.json`
- `frontend/modules/core/pages/admin/audit-log.vue`

---

### T-016 — Logger un warning si `UserRbacProcessor` recoit un body JSON invalide

**Pourquoi :** le processor parse `$request->getContent()` pour savoir quelles cles sont absentes du payload PATCH et restaurer les collections. Si le JSON est invalide (rare mais possible), la restauration est silencieusement sautee — les collections RBAC du user peuvent etre ecrasees par des arrays vides. Impossible a debugger sans log.

**A faire :**

1. Ouvrir `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`.
2. S'assurer que le logger est injecte dans le constructeur (l'ajouter s'il manque) :

```php
use Psr\Log\LoggerInterface;

public function __construct(
    // ... autres dependances
    private readonly LoggerInterface $logger,
) {}
```

3. Modifier la methode (vers ligne 241-248) :

```php
// AVANT
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
    return;
}
```

```php
// APRES
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
    $this->logger->warning(
        'UserRbacProcessor : body JSON invalide, restoreAbsentCollections saute. '
        .'Les collections RBAC peuvent etre ecrasees silencieusement.',
        ['user_id' => $data->getId()],
    );
    return;
}
```

4. Lancer `make test`.

**Fichiers :** `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`

---

## P3 — Polish et dette technique

### T-017 — Mettre a jour `CHANGELOG.md`

**Pourquoi :** le changelog est toujours a `## [0.0.0]` avec un contenu pre-PR. Sans changelog a jour, c'est dur pour un futur dev (ou pour soi-meme dans 6 mois) de savoir ce qui a change.

**A faire :**

1. Remplacer le contenu actuel de `CHANGELOG.md` par (adapter la version au bump effectif) :

```markdown
# Changelog

Liste des evolutions du projet Starseed.

## [0.1.34] - 2026-04-XX

### Added
- Systeme d'audit log (table append-only, API read-only, page admin, timeline)
- Module Sites (multi-tenant, scope automatique, drawer admin)
- Systeme RBAC complet (Role, Permission, UserRbacDrawer, AdminHeadcountGuard)
- Suite E2E Playwright (6 personas, matrice RBAC sidebar)
- Tests PHPUnit pour Sites et Audit

### Changed
- Arborescence frontend : `shared/` + `modules/*/` en layers Nuxt
- Migrations d'init au namespace racine (bug tri FQCN Doctrine 3.x)

### Fixed
- UserRbacProcessor : restauration des collections absentes du payload PATCH
- Drawer utilisateurs : chargement RBAC via `/api/users/{id}/rbac`
- Audit UI : guard stale-data sur fetch concurrent

## [0.0.0]
... contenu initial ...
```

**Fichiers :** `CHANGELOG.md`

---

### T-018 — Supprimer `id` hardcode de `AuditLogEntityTypesResource`

**Pourquoi :** la propriete `$id = 'entity-types'` n'est pas utilisee par le provider. C'est du bruit dans le DTO qui peut confondre un futur lecteur.

**A faire :**

1. Ouvrir `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`.
2. Supprimer la ligne `public readonly string $id = 'entity-types';` (ligne 31).
3. S'assurer que `AuditLogEntityTypesResource` n'a plus besoin de cet `$id` nulle part. Si API Platform leve une erreur pour l'absence d'identifier, remettre la propriete mais avec un commentaire explicite expliquant qu'elle est requise par API Platform pour les ressources singleton.

4. `make test` pour valider.

**Fichiers :** `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`

---

### T-019 — Conditionner `loadSidebar()` apres switch de site

**Pourquoi :** apres chaque switch de site, `useCurrentSite` recharge la sidebar — mais la sidebar de Starseed ne depend d'aucun site. C'est un aller-retour reseau inutile par switch (~100ms + possible flicker visuel).

**A faire :**

1. Ouvrir `frontend/modules/sites/composables/useCurrentSite.ts` (vers ligne 94).
2. Deux options :

*Option A — Supprimer le rechargement* (preferee si la sidebar ne depend actuellement d'aucun site) :

```typescript
// AVANT
await loadSidebar()

// APRES
// Aucun item de sidebar ne depend du site courant aujourd'hui. Si un
// module futur expose des items site-scoped, reintroduire ce reload
// (et le tester).
```

*Option B — Garder le reload avec commentaire explicite* :

```typescript
// Reload defensif : prepare le cas ou /api/sidebar devient site-scoped
// (ex: items RH ou comptabilite par site). Cout : 1 RTT par switch.
await loadSidebar()
```

3. Si option A : tester en switchant de site, verifier qu'il n'y a pas de lag ni de flicker.

**Fichiers :** `frontend/modules/sites/composables/useCurrentSite.ts`

---

### T-020 — Supprimer l'alias `SiteNotAuthorizedException`

**Pourquoi :** la classe `App\Module\Sites\Domain\Exception\SiteNotAuthorizedException` est un alias `final` vide qui etend celle de `Shared\Domain\Exception\`. Elle est dette technique non planifiee — si quelqu'un la referencait dans un nouveau code, on aurait deux versions flottantes.

**A faire :**

1. Chercher les usages :

```bash
grep -rn 'App\\Module\\Sites\\Domain\\Exception\\SiteNotAuthorizedException' src/ tests/
```

2. Remplacer chaque usage par l'import de `Shared\Domain\Exception\SiteNotAuthorizedException`.

3. Supprimer le fichier `src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php`.

4. `make test` pour verifier.

**Fichiers :**
- `src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php` (a supprimer)
- Tous les fichiers qui l'importent (grep)

---

### T-021 — Reduire le couplage Core → Sites (PHPDoc `User`, fixtures, SeedE2ECommand)

**Pourquoi :** la regle projet interdit l'import direct d'un module vers un autre. Dans cette PR, `User.php`, `AppFixtures.php` et `SeedE2ECommand.php` importent respectivement `Site`, `SiteRepositoryInterface`, `SitesFixtures`. C'est documente comme "intentionnel" mais ca viole la regle, donc a documenter plus officiellement ou corriger.

**A faire :** (ticket dette technique — pas urgent, mais a prevoir)

1. Pour `User.php:23` — remplacer les PHPDoc `@var Collection<int, Site>` par `@var Collection<int, SiteInterface>` (l'interface existe deja dans `Shared/Domain/Contract/`).

2. Pour `AppFixtures.php` et `SeedE2ECommand.php` — extraire une interface `SiteFixturesInterface` dans `Shared/` si le couplage est vraiment genant. Sinon, documenter l'exception dans `CLAUDE.md` avec une phrase du type *"Les fixtures et seeds peuvent importer des repositories de modules metiers — c'est accepte car ils vivent hors domaine."*

3. Pas de correction de code urgente. Ouvrir un TODO/issue interne.

**Fichiers :**
- `src/Module/Core/Domain/Entity/User.php`
- `src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php`
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`
- `.claude/rules/architecture.md` (possibles maj)

---

### T-022 — Offrir un bouton "Reessayer" sur les drawers en erreur

**Pourquoi :** si `UserRbacDrawer`, `RoleDrawer` ou `SiteDrawer` echoue son fetch initial (403, 500, reseau), l'utilisateur doit fermer et rouvrir le drawer pour relancer. Un bouton "Reessayer" dans l'etat `loadFailed` ameliore l'UX.

**A faire :** (non bloquant)

1. Dans chaque drawer, ajouter dans le template quand `loadFailed.value === true` :

```vue
<div v-if="loadFailed" class="flex flex-col items-center gap-3 p-4">
    <p class="text-sm text-red-600">{{ t('errors.http.get') }}</p>
    <MalioButton
        type="secondary"
        size="sm"
        :label="t('common.retry')"
        @click="loadData(props.user?.id)"
    />
</div>
```

2. Ajouter `"retry": "Reessayer"` sous `common` dans `fr.json`.

3. Tester : couper reseau, ouvrir drawer, verifier bouton — remettre reseau, cliquer, verifier rechargement.

**Fichiers :**
- `frontend/modules/core/components/UserRbacDrawer.vue`
- `frontend/modules/core/components/RoleDrawer.vue`
- `frontend/modules/sites/components/SiteDrawer.vue`
- `frontend/i18n/locales/fr.json`

---

### T-023 — Garder contre double execution `onMounted` dans `logout.vue`

**Pourquoi :** si la page `logout` est visitee deux fois rapidement, `auth.logout()` est appele deux fois. Lexik est idempotent cote backend donc sans danger, mais deux toasts d'erreur peuvent apparaitre si le reseau flappe. Un simple guard evite ca.

**A faire :**

1. Ouvrir `frontend/modules/core/pages/logout.vue`.
2. Ajouter un guard module-level :

```vue
<script setup lang="ts">
definePageMeta({ layout: 'auth' })

const auth = useAuthStore()
const { resetSidebar } = useSidebar()
const { resetModules } = useModules()
const { resetCurrentSite } = useCurrentSite()
const { resetAuditLog } = useAuditLog()

let logoutInProgress = false

onMounted(async () => {
    if (logoutInProgress) return
    logoutInProgress = true

    try {
        await auth.logout()
    } finally {
        resetSidebar()
        resetModules()
        resetCurrentSite()
        resetAuditLog()
        await navigateTo('/login')
    }
})
</script>
```

**Fichiers :** `frontend/modules/core/pages/logout.vue`

---

## Resume

| Priorite | Tickets      | Estimation |
|----------|--------------|------------|
| **P0**   | T-001 a T-003 | ~45 min total (3 tickets de config/fichier simple) |
| **P1**   | T-004 a T-009 | ~3h (fixes logique + tests + verif) |
| **P2**   | T-010 a T-016 | ~2h30 (conventions + refacto legers) |
| **P3**   | T-017 a T-023 | ~1h30 (polish + dette) |
| **Total**| 23 tickets   | ~7h30 |

> Commence par T-001 — c'est le plus rapide ET le plus impactant (simple suppression d'une ligne).
> Apres les P0, les P1 sont prioritaires : T-007 (flag singleton), T-008 (pagination) et T-004 (crash 500) sont ceux qui peuvent casser en production.
> Pour chaque ticket, fais un commit dedie : `fix(T-XXX) : description courte`.
> Les regles de commit du projet s'appliquent : francais, type + scope + espaces autour du `:`, pas de mention d'outil d'assistance.
