Compare commits

..

6 Commits

Author SHA1 Message Date
gitea-actions d01bbfbc65 chore: bump version to v0.1.43
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 17s
2026-05-28 09:42:08 +00:00
matthieu 92a6343b66 [ERP-43] Migrer les tables Category et CategoryType (#14)
Auto Tag Develop / tag (push) Successful in 8s
## Ticket
- Lesstime : [#43](https://gitea.malio.fr) — Migrer les tables Category et CategoryType (M0 Catalog, position 0.1)

## Contenu
Migration Doctrine `migrations/Version20260527164000.php` (namespace racine `DoctrineMigrations`, règle ABSOLUE Starseed n°11) :
- Table `category_type` : `id INT IDENTITY`, `code VARCHAR(40)` (UNIQUE), `label VARCHAR(120)`
- Table `category` : `id`, `name`, `category_type_id` (FK RESTRICT), `deleted_at` (soft delete), + 4 colonnes Timestampable/Blamable (`created_at`/`updated_at` NOT NULL, `created_by`/`updated_by` nullable FK `"user"` ON DELETE SET NULL)
- Index unique partiel `uq_category_name_type_active` sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL` → matérialise **RG-1.07**
- Index `idx_category_deleted_at`, `idx_category_type_id`, `idx_category_created_by`, `idx_category_updated_by`

## Tests
- `make php-cs-fixer-allow-risky` ✓
- `make db-reset` ✓ (migration exécutée sans erreur)
- `make test` ✓ — 248 tests / 858 assertions, 0 échec
- Vérification psql `\d category` ✓ (index partiel + 8 colonnes + 3 FK avec les bons ON DELETE)

## ⚠ Mode stacked PR
Cette MR cible `feature/ERP-52-creer-pattern-timestampable-blamable-shared` au lieu de `develop`. Quand la MR #13 (ERP-52) sera mergée sur develop, Matthieu repointera la cible de cette MR vers develop.

Reviewer suggéré : Tristan

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #14
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:40:00 +00:00
gitea-actions 02df221a0b chore: bump version to v0.1.42
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 20s
2026-05-28 09:39:02 +00:00
matthieu 6efe7aa8ea [ERP-52] Créer le pattern Timestampable + Blamable Shared (#13)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte
Ticket Lesstime : [#52](https://project.malio-dev.fr/projects/6/tasks/463)
Position dans le groupe M0 : 0.0 (prérequis transverse)

## Implémentation
- 2 interfaces (`TimestampableInterface`, `BlamableInterface`) dans `Shared/Domain/Contract/`
- 1 trait (`TimestampableBlamableTrait`) dans `Shared/Domain/Trait/`
- 1 Subscriber Doctrine (`TimestampableBlamableSubscriber`) dans `Shared/Infrastructure/Doctrine/`
- 1 ligne `resolve_target_entities` ajoutée à `config/packages/doctrine.yaml` (`UserInterface` → `User`)
- 1 test architecture (`EntitiesAreTimestampableBlamableTest`) garde-fou L3 de la spec § 2.8.bis
- 1 test unitaire (`TimestampableBlamableSubscriberTest`) 4 cas

## Décision EXCLUDED (cf. réponse review)
Les 4 entités préexistantes (`User`, `Role`, `Permission`, `Site`) sont **whitelistées** dans `EXCLUDED` avec justification par entrée, plutôt que rétrofitées dans ce ticket. Le rétrofit de `User` et `Site` est documenté en **HP-9 / HP-10** (récursion Blamable + migration → décision archi scopée). Doc mise à jour : spec § 2.8.bis, § 9, et `.claude/rules/backend.md`.

## Tests
- PHPUnit : 5 nouveaux tests, 0 échec, 0 risky (248 tests / 874 assertions au total)
- php-cs-fixer : OK

## Reviewer suggéré
- Tristan

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #13
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:37:18 +00:00
gitea-actions 6c27ac8640 chore: bump version to v0.1.41
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 22s
2026-05-28 09:33:38 +00:00
matthieu 2ef344e22f ci : speed up PR workflow (nuxt build cache + remove broken runner cache) (#21)
Auto Tag Develop / tag (push) Successful in 11s
## Contexte
2 fixes CI extraits de la MR #13 (ERP-52) pour propreté. Ces commits sont indépendants du pattern Shared.

## Contenu
- db6619a ci(frontend) : accelere le job PR (nuxt build + cache node_modules & build Nuxt/Vite)
- 8035a9d ci : retire tout le caching (backend de cache runner injoignable, timeout 4m30)

## Reviewer suggéré
Matthieu (CI infra).

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #21
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:32:27 +00:00
27 changed files with 996 additions and 408 deletions
+21
View File
@@ -40,6 +40,27 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
- Spec complete : @doc/audit-log.md
## Timestampable + Blamable (obligatoire pour entites metier)
Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes a ajouter a l'entite :
```php
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
class MyEntity implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait; // porte les 4 props + getters/setters
// ... reste metier
}
```
- Le `TimestampableBlamableSubscriber` (`Shared/Infrastructure/Doctrine/`) remplit les colonnes au `prePersist` / `preUpdate`. Hors contexte HTTP (CLI, cron, migration), le blame reste `null` (libelle « Systeme » cote front).
- La migration de l'entite doit creer les 4 colonnes (`created_at` / `updated_at` NOT NULL, `created_by` / `updated_by` nullable `ON DELETE SET NULL`).
- **Garde-fou CI** : `tests/Architecture/EntitiesAreTimestampableBlamableTest` echoue si une entite oublie le pattern. Un referentiel statique justifie (ex: `CategoryType`) doit etre explicitement whiteliste dans la constante `EXCLUDED` avec un commentaire.
- Spec complete : @docs/specs/M0-categories/spec-back.md § 2.8 + § 2.8.bis
## Serialization
Pour embarquer une relation dans le JSON (au lieu d'un IRI Hydra), ajouter le groupe du parent sur les proprietes de l'entite cible.
+12 -11
View File
@@ -60,14 +60,9 @@ jobs:
coverage: none
tools: composer:v2
- name: Cache Composer
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: |
composer-
# Cache Composer retire : meme cause que cote front — le backend de cache
# du runner Gitea est injoignable (ETIMEDOUT) et fait timeouter le step
# ~4 min 30. A re-activer si le serveur de cache du runner est repare.
- name: Install PHP dependencies
run: composer install --no-interaction --no-progress --prefer-dist
@@ -99,12 +94,15 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
# Pas de `cache: npm` : le backend de cache du runner Gitea est injoignable
# (ETIMEDOUT) et chaque tentative de restauration attend ~4 min 30 avant de
# timeout — c'est ce qui plombait le job. Node 22 est deja dans le
# tool-cache du runner (install instantane), et `npm ci` a froid ne prend
# que ~30s. A re-activer si le serveur de cache du runner est repare.
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install Node dependencies
run: npm ci
@@ -115,5 +113,8 @@ jobs:
- name: Unit tests (Vitest)
run: npm run test
# `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off
# (SPA), le prerender de generate n'apporte rien a une quality gate — on
# veut seulement valider que le bundle compile.
- name: Build production (nuxt build)
run: npm run build:dist
run: npm run build
+4
View File
@@ -33,6 +33,10 @@ doctrine:
# `App\Module\Sites\Domain\Entity\Site` dans User.php.
resolve_target_entities:
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
# Cible des ManyToOne created_by / updated_by du TimestampableBlamableTrait.
# Permet a Shared de referencer UserInterface dans ses ORM mappings sans
# importer la classe concrete du module Core (cf. spec-back M0 § 2.8).
Symfony\Component\Security\Core\User\UserInterface: App\Module\Core\Domain\Entity\User
mappings:
Core:
type: attribute
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.40'
app.version: '0.1.43'
+17
View File
@@ -441,6 +441,21 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
Coût d'écriture : 1h. Coût en CI : ~50ms. Bénéfice : 0 oubli possible. À écrire dans le ticket 0.0.
#### Décision M0 sur la whitelist `EXCLUDED` (ERP-52)
Au moment d'introduire le pattern (ticket 0.0), 4 entités préexistantes vivent déjà sous `src/Module/*/Domain/Entity/` : `User`, `Role`, `Permission` (Core) et `Site` (Sites). Aucune n'implémente le pattern. Le test L3 les détecterait et passerait au rouge.
**Décision** : on **whiteliste explicitement ces 4 entités** dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` avec une justification par entrée, plutôt que de les rétrofiter dans ERP-52 :
| Entité | Justification du `EXCLUDED` |
|---|---|
| `User` | Référentiel d'authentification ; `createdAt` géré manuellement dans le constructeur. Rétrofit non trivial : impose de trancher la récursion Blamable (un `User` créé par un `User`) et casse des tests existants → **HP-9**. |
| `Role` | Référentiel RBAC synchronisé via `app:sync-permissions`, pas de traçabilité user-driven nécessaire. |
| `Permission` | Idem `Role` (synchronisé, pas piloté utilisateur). |
| `Site` | Référentiel admin-managed, rétrofit à intégrer dans un futur module Sites v2 → **HP-10**. |
**Règle dure pour la suite** : toute **nouvelle** entité métier (`Category` au M0, puis `Client`, `Fournisseur`, `Prestataire`, etc.) **doit** implémenter `TimestampableInterface` + `BlamableInterface` via le Trait. La whitelist `EXCLUDED` est réservée aux référentiels statiques justifiés (ex : `CategoryType` au ticket 0.2) — toute nouvelle entrée doit être documentée.
#### Tests Subscriber
Tests unitaires du Subscriber : créer une entité de test minimale (fixture interne aux tests) qui `use` le Trait + implements les interfaces, vérifier que `prePersist` + `preUpdate` remplissent les 4 colonnes. À écrire dans le ticket 0.0.
@@ -979,6 +994,8 @@ Les deux mécanismes sont indépendants : on peut désactiver `#[Auditable]` (pa
- **HP-6** : **Filtres avancés / recherche serveur** dans la liste. Pas pertinent à 300 entrées (pagination front).
- **HP-7** : **Catégories hiérarchiques** (parent / enfant). Pas demandé. Si besoin futur → migration ajout colonne `parent_id` + spec dédiée.
- **HP-8** : **Création des rôles métier Bureau / Compta / Commerciale / Usine.** Ces rôles font partie du modèle MALIO mais leur seed initial dans `role` + leur attribution aux users est hors du périmètre M0 (probablement un M-RBAC dédié, ou seedés dans `AppFixtures` / `SeedE2ECommand` au fil des modules).
- **HP-9** : **Rétrofit de `User` vers Timestampable + Blamable.** L'entité `User` est whitelistée dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` au M0 (cf. § 2.8.bis). Son rétrofit nécessite une **décision archi dédiée** : récursion Blamable (un `User` créé/modifié par un `User`, FK auto-référente `created_by` / `updated_by` sur la table `user`), impact sur le `createdAt` déjà géré dans le constructeur, et migration des données existantes. À traiter dans un ticket scopé hors M0.
- **HP-10** : **Rétrofit de `Site` vers Timestampable + Blamable.** Même logique que HP-9 pour le référentiel `Site` (whitelisté `EXCLUDED`). À intégrer dans un futur module Sites v2, avec la migration ajoutant les 4 colonnes + FK `user`.
## 10. Liens & dépendances
+2 -3
View File
@@ -4,7 +4,6 @@
<MalioSidebar
v-model="ui.sidebarCollapsed"
:sections="translatedSections"
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
>
<template #logo>
<img src="/LOGO_MALIO.png" alt="Malio"/>
@@ -17,10 +16,10 @@
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<SiteSelector v-if="showSiteSelector"/>
<main
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-[170px]">
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
<div
aria-hidden="true"
class="pointer-events-none sticky top-0 z-30 h-[47px] flex-shrink-0 bg-white"/>
class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12"/>
<slot/>
</main>
</div>
-3
View File
@@ -90,10 +90,7 @@
"load_more": "Voir plus"
},
"filters": {
"title": "Filtres",
"apply": "Voir les résultats",
"reset": "Réinitialiser",
"date_range": "Date à date",
"date_from": "Du",
"date_to": "Au",
"entity_type": "Type d'entité",
@@ -1,7 +1,7 @@
<template>
<div>
<PageHeader>{{ $t('commercial.title') }}</PageHeader>
<p class="text-neutral-500">{{ $t('commercial.welcome') }}</p>
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('commercial.title') }}</h1>
<p class="mt-4 text-neutral-500">{{ $t('commercial.welcome') }}</p>
</div>
</template>
@@ -0,0 +1,66 @@
<template>
<div class="rounded-lg border border-neutral-200 overflow-hidden">
<!-- En-tete du groupe avec checkbox "tout selectionner" -->
<div class="flex items-center gap-3 bg-neutral-50 px-4 py-3 border-b border-neutral-200">
<MalioCheckbox
:id="`group-${module}`"
:label="moduleLabel"
:model-value="allSelected"
label-class="font-semibold text-sm text-neutral-700 capitalize"
@update:model-value="toggleAll"
/>
<span class="ml-auto text-xs text-neutral-400">
{{ selectedCount }}/{{ permissions.length }}
</span>
</div>
<!-- Liste des permissions individuelles -->
<div class="grid grid-cols-1 gap-1 p-3 sm:grid-cols-2">
<MalioCheckbox
v-for="perm in permissions"
:key="perm.id"
:id="`perm-${perm.id}`"
:label="perm.label"
:model-value="selectedIds.has(perm.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => togglePermission(perm.id, val)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Permission } from '~/shared/types/rbac'
const props = defineProps<{
module: string
moduleLabel: string
permissions: Permission[]
selectedIds: Set<number>
}>()
const emit = defineEmits<{
toggle: [permissionId: number, selected: boolean]
toggleAll: [module: string, selected: boolean]
}>()
// Nombre de permissions selectionnees dans ce groupe
const selectedCount = computed(() =>
props.permissions.filter(p => props.selectedIds.has(p.id)).length
)
// Vrai si toutes les permissions du groupe sont selectionnees
const allSelected = computed(() =>
props.permissions.length > 0 && selectedCount.value === props.permissions.length
)
// Emet l'evenement de bascule pour une permission individuelle
function togglePermission(id: number, selected: boolean) {
emit('toggle', id, selected)
}
// Emet l'evenement de bascule pour toutes les permissions du groupe
function toggleAll(selected: boolean) {
emit('toggleAll', props.module, selected)
}
</script>
+36 -81
View File
@@ -1,17 +1,11 @@
<template>
<MalioDrawer
:model-value="modelValue"
:title="isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole')"
drawer-class="w-full max-w-lg"
header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<h2 class="text-[24px] font-bold">
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
</h2>
</template>
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
<!-- Champs du role -->
<MalioInputText
v-model="form.label"
@@ -50,71 +44,45 @@
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
{{ t('admin.roles.permissions.noPermissions') }}
</div>
<!-- Un panneau d'accordeon par module (mode multiple) ; le compteur
selectionnees/total reste visible dans l'en-tete replie. -->
<MalioAccordion v-else v-model="openModules">
<MalioAccordionItem
<div class="flex flex-col gap-4">
<PermissionGroup
v-for="group in permissionsByModule"
:key="group.module"
:value="group.module"
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
header-class="capitalize"
>
<div class="flex flex-col gap-3">
<!-- Tout selectionner pour ce module -->
<MalioCheckbox
:id="`role-group-${group.module}`"
:label="t('admin.roles.permissions.selectAll')"
:model-value="allSelectedFor(group)"
label-class="font-semibold text-sm text-neutral-700"
@update:model-value="(val: boolean) => handleToggleAll(group.module, val)"
/>
<div class="flex flex-col gap-2">
<MalioCheckbox
v-for="perm in group.permissions"
:id="`role-perm-${perm.id}`"
:key="perm.id"
:label="perm.label"
:model-value="selectedPermissionIds.has(perm.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => handleTogglePermission(perm.id, val)"
/>
</div>
</div>
</MalioAccordionItem>
</MalioAccordion>
:module="group.module"
:module-label="group.module"
:permissions="group.permissions"
:selected-ids="selectedPermissionIds"
@toggle="handleTogglePermission"
@toggle-all="handleToggleAll"
/>
</div>
</div>
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="role?.isSystem"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving || permissionsLoadFailed"
@click="handleSave"
/>
</div>
</form>
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-[150px]"
:disabled="role?.isSystem"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
button-class="w-[150px]"
:disabled="saving || permissionsLoadFailed"
@click="handleSave"
/>
</template>
</MalioDrawer>
</template>
@@ -156,19 +124,6 @@ const form = ref({
const selectedPermissionIds = ref(new Set<number>())
// Modules ouverts dans l'accordeon des permissions (mode multiple)
const openModules = ref<string[]>([])
// Nombre de permissions selectionnees pour un module donne
function selectedCountFor(group: PermissionModule): number {
return group.permissions.filter(p => selectedPermissionIds.value.has(p.id)).length
}
// Vrai si toutes les permissions du module sont selectionnees
function allSelectedFor(group: PermissionModule): boolean {
return group.permissions.length > 0 && selectedCountFor(group) === group.permissions.length
}
const isEditMode = computed(() => props.role !== null)
// Grouper les permissions par module
@@ -1,17 +1,11 @@
<template>
<MalioDrawer
:model-value="modelValue"
drawer-class="w-full max-w-[450px]"
header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
:title="t('admin.users.drawer.title', { username: user?.username ?? '' })"
drawer-class="w-full max-w-lg"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<h2 class="text-[24px] font-bold">
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
</h2>
</template>
<div class="flex flex-col gap-4 py-4">
<div class="flex flex-col gap-6 p-4">
<!-- Etat d'erreur de chargement des referentiels : bloque la
sauvegarde pour empecher un ecrasement silencieux des droits. -->
<div
@@ -66,39 +60,18 @@
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
{{ t('admin.roles.permissions.noPermissions') }}
</div>
<!-- Un panneau d'accordeon par module (mode multiple) ; le compteur
selectionnees/total reste visible dans l'en-tete replie. -->
<MalioAccordion v-else v-model="openDirectModules">
<MalioAccordionItem
<div class="flex flex-col gap-4">
<PermissionGroup
v-for="group in permissionsByModule"
:key="group.module"
:value="group.module"
:title="`${group.module} (${directSelectedCount(group)}/${group.permissions.length})`"
header-class="capitalize"
>
<div class="flex flex-col gap-3">
<!-- Tout selectionner pour ce module -->
<MalioCheckbox
:id="`direct-group-${group.module}`"
:label="t('admin.roles.permissions.selectAll')"
:model-value="directAllSelected(group)"
label-class="font-semibold text-sm text-neutral-700"
@update:model-value="(val: boolean) => handleToggleAll(group.module, val)"
/>
<div class="flex flex-col gap-2">
<MalioCheckbox
v-for="perm in group.permissions"
:id="`direct-perm-${perm.id}`"
:key="perm.id"
:label="perm.label"
:model-value="selectedDirectPermissionIds.has(perm.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => handleTogglePermission(perm.id, val)"
/>
</div>
</div>
</MalioAccordionItem>
</MalioAccordion>
:module="group.module"
:module-label="group.module"
:permissions="group.permissions"
:selected-ids="selectedDirectPermissionIds"
@toggle="handleTogglePermission"
@toggle-all="handleToggleAll"
/>
</div>
</div>
<!-- Section Sites autorises (ticket 2 module Sites) -->
@@ -130,25 +103,21 @@
<EffectivePermissions :permissions="effectivePermissions" />
</div>
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
:label="t('common.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving || loadFailed"
@click="handleSave"
/>
</div>
</div>
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton
:label="t('common.cancel')"
variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
button-class="w-[150px]"
:disabled="saving || loadFailed"
@click="handleSave"
/>
</template>
</MalioDrawer>
</template>
@@ -189,19 +158,6 @@ const selectedRoleIds = ref(new Set<number>())
const selectedDirectPermissionIds = ref(new Set<number>())
const selectedSiteIds = ref(new Set<number>())
// Modules ouverts dans l'accordeon des permissions directes (mode multiple)
const openDirectModules = ref<string[]>([])
// Nombre de permissions directes selectionnees pour un module donne
function directSelectedCount(group: PermissionModule): number {
return group.permissions.filter(p => selectedDirectPermissionIds.value.has(p.id)).length
}
// Vrai si toutes les permissions directes du module sont selectionnees
function directAllSelected(group: PermissionModule): boolean {
return group.permissions.length > 0 && directSelectedCount(group) === group.permissions.length
}
// Detecter l'auto-edition
const isSelfEdit = computed(() => props.user?.id === auth.user?.id)
+165 -156
View File
@@ -1,22 +1,95 @@
<template>
<div>
<PageHeader>
{{ t('admin.auditLog.title') }}
<template #actions>
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.auditLog.title') }}
</h1>
</div>
<!-- Filtres -->
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
leur `label` flottant interne pour ne pas mixer deux patterns de label.
A revoir une fois le composant calendar Malio développé -->
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
exposera un datetime picker. Cf. exception documentee dans
CLAUDE.md (section "Composants formulaires"). -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_from') }}
</label>
<input
v-model="filters.performedAtAfter"
type="datetime-local"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
</div>
<!-- TODO(malio-ui): idem ci-dessus. -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_to') }}
</label>
<input
v-model="filters.performedAtBefore"
type="datetime-local"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.entity_type') }}
</label>
<div class="[&>div>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedEntityTypes"
:options="entityTypeOptions"
:display-select-all="true"
:display-tag="true"
min-width="w-full"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.user') }}
</label>
<MalioInputText
v-model="performedByInput"
icon-name="mdi:account-search"
input-class="text-sm"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.action') }}
</label>
<div class="[&>div>div]:!mt-0">
<MalioSelect
v-model="actionValue"
:options="actionOptions"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
</div>
<div class="mt-3 flex justify-end">
<MalioButton
variant="tertiary"
:label="t('audit.filters.title')"
icon-name="mdi:tune"
icon-position="left"
iconSize="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters"
:label="t('audit.filters.reset')"
button-class="text-xs"
@click="resetFilters"
/>
</template>
</PageHeader>
</div>
</section>
<!-- Tableau -->
<MalioDataTable
class="mt-4"
:columns="columns"
:items="rows"
:total-items="totalItems"
@@ -50,91 +123,12 @@
</template>
</MalioDataTable>
<!-- Drawer de filtres : etat brouillon, applique uniquement au clic sur
"Voir les resultats". `body-class="p-0"` pour que l'accordeon aille
bord a bord (les items portent leur propre px-7). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('audit.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
<span>{{ t('audit.filters.date_from') }}</span>
<MalioDateTime v-model="draftDateFrom" />
<span>{{ t('audit.filters.date_to') }}</span>
<MalioDateTime v-model="draftDateTo" />
</div>
</MalioAccordionItem>
<!-- Type d'entite : cases a cocher (multi-selection) -->
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
<div class="flex flex-col gap-4">
<MalioCheckbox
v-for="opt in entityTypeOptions"
:id="`filter-entity-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftEntityTypes.includes(opt.value)"
@update:model-value="(val: boolean) => toggleEntity(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Action : boutons radio (selection unique, '' = toutes) -->
<MalioAccordionItem :title="t('audit.filters.action')" value="action">
<MalioRadioButton
v-for="opt in actionOptions"
:key="opt.value"
v-model="draftAction"
name="audit-action"
:value="opt.value"
:label="opt.label"
/>
</MalioAccordionItem>
<!-- Utilisateur : recherche texte (ILIKE partiel cote backend) -->
<MalioAccordionItem :title="t('audit.filters.user')" value="user">
<MalioInputText
v-model="draftPerformedBy"
icon-name="mdi:account-search"
/>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('audit.filters.reset')"
button-class="w-[150px]"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('audit.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
<MalioDrawer
v-model="drawerOpen"
:title="drawerTitle"
drawer-class="max-w-2xl"
>
<template #header>
<h2 class="text-[24px] font-bold">
{{ drawerTitle }}
</h2>
</template>
<div v-if="selectedEntry">
<AuditLogDetail :entry="selectedEntry" />
<div class="mt-4 border-t border-gray-200 pt-3">
@@ -155,7 +149,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
const { t, te } = useI18n()
@@ -179,11 +173,8 @@ if (!can('core.audit_log.view')) {
useHead({ title: t('admin.auditLog.title') })
// Etat des filtres APPLIQUES : pilote `loadEntries`. Local uniquement, JAMAIS
// persiste dans l'URL (cf. regle CLAUDE.md "Tableau : pas de persistance URL").
// `performedAtAfter`/`performedAtBefore` stockent une date+heure ISO naive
// (`YYYY-MM-DDTHH:MM:00`, fournie par MalioDateTime), convertie en ISO UTC
// au moment du fetch.
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
// CLAUDE.md "Tableau : pas de persistance URL").
const filters = reactive<AuditLogFilters>({
performedAtAfter: undefined,
performedAtBefore: undefined,
@@ -194,23 +185,26 @@ const filters = reactive<AuditLogFilters>({
itemsPerPage: 10,
})
// Etat BROUILLON du drawer de filtres : edite librement, recopie dans `filters`
// uniquement au clic sur "Voir les resultats". Permet d'annuler une saisie en
// fermant le drawer sans relancer de requete.
const filterDrawerOpen = ref(false)
const draftDateFrom = ref<string | null>(null)
const draftDateTo = ref<string | null>(null)
const draftEntityTypes = ref<string[]>([])
const draftAction = ref<string>('')
const draftPerformedBy = ref<string>('')
// Liste des entity types (distincts) pour alimenter les cases a cocher.
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
const selectedEntityTypes = ref<(string | number)[]>([])
const entityTypes = ref<string[]>([])
// On garde l'identifiant technique comme `value` pour l'envoi API, mais on
// affiche le libelle traduit quand il existe (fallback: identifiant brut).
const entityTypeOptions = computed(() =>
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
)
// Actions : '' = "toutes". Sert d'options aux boutons radio.
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
// pas binder directement un `string | undefined` reactive.
const performedByInput = ref<string>('')
// Action : '' = "toutes les actions". On declare l'option dans `actionOptions`
// plutot que via `emptyOptionLabel` (qui n'inclut pas l'option vide dans
// `props.options`, donc `selectedLabel` reste vide). On evite aussi `value: null`
// car MalioSelect grise visuellement les options dont la valeur est `null`
// (Select.vue:137) — on utilise donc une chaine vide comme sentinelle.
const actionValue = ref<string>('')
const actionOptions = [
{ value: '', label: t('audit.filters.all_actions') },
{ value: 'create', label: t('audit.action.create') },
@@ -265,55 +259,29 @@ const isFiltered = computed(() =>
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
let requestToken = 0
// Ouvre le drawer en recopiant l'etat applique vers le brouillon, pour que la
// reouverture reflete les filtres actifs.
function openFilters(): void {
draftDateFrom.value = filters.performedAtAfter ?? null
draftDateTo.value = filters.performedAtBefore ?? null
draftEntityTypes.value = Array.isArray(filters.entityType)
? [...filters.entityType]
: (filters.entityType ? [filters.entityType] : [])
draftAction.value = filters.action ?? ''
draftPerformedBy.value = filters.performedBy ?? ''
filterDrawerOpen.value = true
}
// Bascule un type d'entite dans le brouillon (multi-selection).
function toggleEntity(value: string, selected: boolean): void {
const set = new Set(draftEntityTypes.value)
if (selected) set.add(value)
else set.delete(value)
draftEntityTypes.value = [...set]
}
// "Reinitialiser" : vide le brouillon ET les filtres actifs, puis recharge.
// La remise a zero s'applique immediatement (la table revient a la liste
// complete) ; le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftDateFrom.value = null
draftDateTo.value = null
draftEntityTypes.value = []
draftAction.value = ''
draftPerformedBy.value = ''
// Pendant un reset, on suspend temporairement les watchers pour ne pas
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
// et les fetchs partent quand meme. Un seul loadEntries() est appele
// explicitement apres la liberation.
let watchersSuspended = false
async function resetFilters(): Promise<void> {
watchersSuspended = true
filters.performedAtAfter = undefined
filters.performedAtBefore = undefined
filters.entityType = undefined
filters.action = undefined
filters.performedBy = undefined
filters.action = undefined
filters.page = 1
loadEntries()
}
// "Voir les resultats" : applique le brouillon, recharge et ferme le drawer.
function applyFilters(): void {
filters.performedAtAfter = draftDateFrom.value ?? undefined
filters.performedAtBefore = draftDateTo.value ?? undefined
filters.entityType = draftEntityTypes.value.length > 0 ? [...draftEntityTypes.value] : undefined
filters.action = draftAction.value === '' ? undefined : draftAction.value
filters.performedBy = draftPerformedBy.value.trim() === '' ? undefined : draftPerformedBy.value.trim()
filters.page = 1
filterDrawerOpen.value = false
selectedEntityTypes.value = []
performedByInput.value = ''
actionValue.value = ''
// Les watchers mute de Vue 3 se planifient en microtask : on attend
// leur execution avec le flag `true`, puis on libere.
await nextTick()
watchersSuspended = false
loadEntries()
}
@@ -323,8 +291,7 @@ async function loadEntries(): Promise<void> {
try {
const data = await fetchLogsCached({
...filters,
// MalioDateTime fournit une date+heure sans fuseau (heure locale) ;
// on la convertit en ISO UTC pour l'API (bornes exactes, intervalle inclusif).
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API.
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
})
@@ -348,9 +315,14 @@ async function loadEntries(): Promise<void> {
}
}
// Debounce auto-importe depuis `frontend/shared/utils/debounce.ts` : evite
// un refetch a chaque frappe sur le champ texte performedBy (reseau + SQL)
// et laisse l'utilisateur finir sa saisie avant de lancer la requete.
const debouncedReload = debounce(() => loadEntries(), 300)
function toIso(localDateTime: string): string {
// MalioDateTime emet une date+heure sans fuseau (heure murale locale) ;
// on laisse Date() generer l'ISO UTC correspondant pour l'API.
// datetime-local n'a pas de timezone : on assume heure locale et on
// laisse le navigateur generer l'ISO via Date().
return new Date(localDateTime).toISOString()
}
@@ -396,6 +368,43 @@ function onPerPageChange(value: number): void {
loadEntries()
}
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
watch(selectedEntityTypes, values => {
if (watchersSuspended) return
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
filters.page = 1
loadEntries()
})
// Sync MalioSelect action -> filters.action.
watch(actionValue, value => {
if (watchersSuspended) return
filters.action = value === '' ? undefined : value
filters.page = 1
loadEntries()
})
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
// refetch par caractere. Le reset passe par debouncedReload egalement pour
// coalescer si plusieurs watchers tirent en meme temps.
watch(performedByInput, value => {
if (watchersSuspended) return
filters.performedBy = value === '' ? undefined : value
filters.page = 1
debouncedReload()
})
// Synchronisation reactive : tout changement de dates declenche un fetch +
// reset de la pagination a la page 1.
watch(
() => [filters.performedAtAfter, filters.performedAtBefore],
() => {
if (watchersSuspended) return
filters.page = 1
loadEntries()
},
)
onMounted(async () => {
// Charge les entity types en parallele de la liste principale : un
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
+14 -12
View File
@@ -1,20 +1,22 @@
<template>
<div>
<PageHeader>
{{ t('admin.roles.title') }}
<template #actions>
<MalioButton
v-if="can('core.roles.manage')"
:label="t('admin.roles.newRole')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</template>
</PageHeader>
<!-- En-tete -->
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.roles.title') }}
</h1>
<MalioButton
v-if="can('core.roles.manage')"
:label="t('admin.roles.newRole')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</div>
<!-- Table des roles -->
<MalioDataTable
class="mt-6"
:columns="columns"
:items="roleItems"
:total-items="roles.length"
+7 -1
View File
@@ -1,9 +1,15 @@
<template>
<div>
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
<!-- En-tete -->
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.users.title') }}
</h1>
</div>
<!-- Table des utilisateurs -->
<MalioDataTable
class="mt-6"
:columns="columns"
:items="userItems"
:total-items="users.length"
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<div>
<PageHeader>{{ $t('dashboard.title') }}</PageHeader>
<p class="text-neutral-500">{{ $t('dashboard.welcome') }}</p>
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
<p class="mt-4 text-neutral-500">{{ $t('dashboard.welcome') }}</p>
</div>
</template>
@@ -1,17 +1,11 @@
<template>
<MalioDrawer
:model-value="modelValue"
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
drawer-class="w-full max-w-lg"
header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<h2 class="text-[24px] font-bold">
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
</h2>
</template>
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
<MalioInputText
v-model="form.name"
:label="t('admin.sites.form.name')"
@@ -76,35 +70,30 @@
</p>
</div>
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving || !isValidHex"
@click="handleSave"
/>
</div>
</form>
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-[150px]"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
button-class="w-[150px]"
:disabled="saving || !isValidHex"
@click="handleSave"
/>
</template>
</MalioDrawer>
</template>
+14 -12
View File
@@ -1,20 +1,22 @@
<template>
<div>
<PageHeader>
{{ t('admin.sites.title') }}
<template #actions>
<MalioButton
v-if="can('sites.manage')"
:label="t('admin.sites.newSite')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</template>
</PageHeader>
<!-- En-tete -->
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.sites.title') }}
</h1>
<MalioButton
v-if="can('sites.manage')"
:label="t('admin.sites.newSite')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</div>
<!-- Table des sites -->
<MalioDataTable
class="mt-6"
:columns="columns"
:items="siteItems"
:total-items="sites.length"
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.7.1",
"@malio/layer-ui": "^1.5.0",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.7.1",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.1/layer-ui-1.7.1.tgz",
"integrity": "sha512-RYMMappWt/fgjD+BM7//h2O6kxD6WH9Fui8hoC29xtKySRQsqD61XKTdR7BRRkpktbxKmV39q/hblyAFBqV5yw==",
"version": "1.5.0",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.5.0/layer-ui-1.5.0.tgz",
"integrity": "sha512-uVuG8kRakWgpWYQCMUf1LFD+gjx0iRFfNJn/jlqjxiZmZyGZMckcMW2qA9hGZBiheBsTJWw1pRR4ufuyAYPY0A==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@malio/layer-ui": "^1.7.1",
"@malio/layer-ui": "^1.5.0",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -1,12 +0,0 @@
<template>
<!-- Entete de page standard : source unique du style des titres.
Slot par defaut = texte du titre, slot #actions = boutons a droite. -->
<div class="mb-[44px] flex items-center justify-between gap-4">
<h1 class="text-[32px] font-semibold text-primary-500">
<slot/>
</h1>
<div v-if="$slots.actions" class="shrink-0">
<slot name="actions"/>
</div>
</div>
</template>
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M0 — Catalog : creation des tables `category_type` (referentiel) et `category`.
*
* Le referentiel `category_type` est cree vide ; ses valeurs seront seedees
* ulterieurement (cf. spec-back M0 § 9 HP-1).
*
* Index unique partiel sur (LOWER(name), category_type_id) WHERE deleted_at
* IS NULL : permet la recreation d'une categorie apres suppression logique
* (cf. RG-1.07). Postgres supporte nativement le `CREATE UNIQUE INDEX ... WHERE`.
*
* Les 4 colonnes Timestampable/Blamable (`created_at`, `updated_at`,
* `created_by`, `updated_by`) materialisent le pattern Shared (cf. ERP-52,
* spec-back M0 § 2.8) : NOT NULL pour les dates (remplies par le subscriber),
* nullable + ON DELETE SET NULL pour les FK user (creation hors contexte HTTP
* et suppression d'un user sans bloquer les categories existantes).
*
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE
* Starseed n°11) : avec plusieurs migrations_paths, Doctrine Migrations 3.x
* trie par FQCN alphabetique et non par version timestamp → l'init des tables
* d'un module doit vivre au namespace racine pour garantir l'ordre sur base
* vide.
*/
final class Version20260527164000 extends AbstractMigration
{
public function getDescription(): string
{
return 'M0 Catalog : tables category_type et category, index unique partiel.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE category_type (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(40) NOT NULL,
label VARCHAR(120) NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uq_category_type_code ON category_type (code)');
$this->addSql(<<<'SQL'
CREATE TABLE category (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(120) NOT NULL,
category_type_id INT NOT NULL,
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_category_type
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT,
CONSTRAINT fk_category_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_category_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// Unicite (name, type) case-insensitive, seulement sur les non-soft-deleted.
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_category_name_type_active
ON category (LOWER(name), category_type_id)
WHERE deleted_at IS NULL
SQL);
$this->addSql('CREATE INDEX idx_category_deleted_at ON category (deleted_at)');
$this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)');
$this->addSql('CREATE INDEX idx_category_created_by ON category (created_by)');
$this->addSql('CREATE INDEX idx_category_updated_by ON category (updated_by)');
}
public function down(Schema $schema): void
{
// Ordre important : `category` porte les FK vers `category_type`.
$this->addSql('DROP TABLE category');
$this->addSql('DROP TABLE category_type');
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Contrat lu par le TimestampableBlamableSubscriber.
*
* Toute entite qui l'implemente voit ses colonnes `created_by` / `updated_by`
* remplies automatiquement avec l'utilisateur authentifie (ou laissees a null
* hors contexte HTTP : CLI, cron, migration).
*
* Le type-hint cible `Symfony\Component\Security\Core\User\UserInterface`
* (deja implementee par App\Module\Core\Domain\Entity\User) pour eviter de
* coupler Shared a Module/Core. La classe concrete est resolue par Doctrine
* via `resolve_target_entities` (cf. config/packages/doctrine.yaml).
*/
interface BlamableInterface
{
public function getCreatedBy(): ?UserInterface;
public function setCreatedBy(?UserInterface $user): void;
public function getUpdatedBy(): ?UserInterface;
public function setUpdatedBy(?UserInterface $user): void;
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use DateTimeImmutable;
/**
* Contrat lu par le TimestampableBlamableSubscriber.
*
* Toute entite qui l'implemente voit ses colonnes `created_at` / `updated_at`
* remplies automatiquement au prePersist / preUpdate. Le porteur des colonnes
* et des accesseurs est le TimestampableBlamableTrait.
*/
interface TimestampableInterface
{
public function getCreatedAt(): ?DateTimeImmutable;
public function setCreatedAt(DateTimeImmutable $createdAt): void;
public function getUpdatedAt(): ?DateTimeImmutable;
public function setUpdatedAt(DateTimeImmutable $updatedAt): void;
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Trait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Trait Doctrine qui porte les 4 colonnes Timestampable + Blamable.
*
* Usage : `use TimestampableBlamableTrait;` dans l'entite, +
* `implements TimestampableInterface, BlamableInterface`. Le
* TimestampableBlamableSubscriber remplit les colonnes automatiquement
* au prePersist / preUpdate.
*
* Les Groups Serializer utilisent une convention `default:read` agregee :
* pour exposer les 4 colonnes dans une reponse API d'une entite X, ajouter
* `default:read` au normalizationContext aux cotes du groupe `x:read`.
*/
trait TimestampableBlamableTrait
{
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
#[Groups(['default:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(name: 'updated_at', type: 'datetime_immutable')]
#[Groups(['default:read'])]
private ?DateTimeImmutable $updatedAt = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['default:read'])]
private ?UserInterface $createdBy = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'updated_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['default:read'])]
private ?UserInterface $updatedBy = null;
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): void
{
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
public function getCreatedBy(): ?UserInterface
{
return $this->createdBy;
}
public function setCreatedBy(?UserInterface $user): void
{
$this->createdBy = $user;
}
public function getUpdatedBy(): ?UserInterface
{
return $this->updatedBy;
}
public function setUpdatedBy(?UserInterface $user): void
{
$this->updatedBy = $user;
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Doctrine;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Listener Doctrine global qui remplit automatiquement les colonnes
* Timestampable + Blamable.
*
* Pattern aligne sur AuditListener (cf.
* src/Module/Core/Infrastructure/Doctrine/AuditListener.php) : declare via
* #[AsDoctrineListener], auto-wire par le DoctrineBundle.
*
* Regle Blamable : si aucun utilisateur n'est authentifie (CLI, cron,
* migration), les FK `created_by` / `updated_by` restent a null. L'affichage
* front gere le libelle « Systeme » pour null.
*/
#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
final class TimestampableBlamableSubscriber
{
public function __construct(private readonly Security $security) {}
public function prePersist(PrePersistEventArgs $args): void
{
$entity = $args->getObject();
$now = new DateTimeImmutable();
$user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) {
$entity->setCreatedAt($now);
$entity->setUpdatedAt($now);
}
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
$entity->setCreatedBy($user);
$entity->setUpdatedBy($user);
}
}
public function preUpdate(PreUpdateEventArgs $args): void
{
$entity = $args->getObject();
$user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) {
$entity->setUpdatedAt(new DateTimeImmutable());
}
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
$entity->setUpdatedBy($user);
}
}
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Tests\Architecture;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use Doctrine\ORM\Mapping\Entity;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\Finder\Finder;
use function in_array;
/**
* Garde-fou architecture (niveau L3 de la spec § 2.8.bis).
*
* Scanne toutes les entites Doctrine sous `src/Module/<module>/Domain/Entity/`
* et verifie qu'elles implementent TimestampableInterface ET BlamableInterface
* (via TimestampableBlamableTrait). Empeche tout oubli du pattern sur une
* nouvelle entite metier : la CI passe au rouge.
*
* @internal
*/
final class EntitiesAreTimestampableBlamableTest extends TestCase
{
/**
* Entites explicitement exemptees du pattern.
*
* Au M0, on whiteliste les 4 entites preexistantes du noyau (creees avant
* l'introduction du pattern) : leur retrofit est une decision archi a part
* entiere, hors scope ERP-52.
*
* - User : referentiel d'authentification, createdAt gere manuellement dans
* le constructeur. Retrofit hors scope M0 (cf. HP-9) : impose de trancher
* la recursion Blamable (un User cree par un User) + casse des tests
* existants.
* - Role : referentiel RBAC synchronise via `app:sync-permissions`, pas de
* tracabilite user-driven necessaire.
* - Permission : idem Role (synchronise, pas pilote utilisateur).
* - Site : referentiel admin-managed, a integrer dans un futur module Sites
* v2 (cf. HP-10).
*
* Les futurs referentiels statiques (ex: CategoryType au ticket 0.2)
* s'ajoutent ici avec une justification.
*/
private const EXCLUDED = [
User::class,
Role::class,
Permission::class,
Site::class,
];
public function testAllBusinessEntitiesImplementBothInterfaces(): void
{
// Garde : chaque entree de la whitelist doit pointer sur une classe
// reelle. Empeche un FQCN errone de masquer silencieusement un oubli.
foreach (self::EXCLUDED as $excluded) {
self::assertTrue(class_exists($excluded), sprintf('Classe whitelistee inexistante : %s', $excluded));
}
$finder = new Finder()
->files()
->in(__DIR__.'/../../src/Module')
->path('Domain/Entity')
->name('*.php')
;
// Garde : si le scan ne trouve rien, le chemin est casse — le test
// deviendrait un faux positif vert. On verifie qu'il a du grain a moudre.
self::assertNotEmpty(iterator_to_array($finder), 'Aucune entite scannee : chemin src/Module invalide ?');
foreach ($finder as $file) {
$fqcn = $this->extractFqcn($file->getRealPath());
if (null === $fqcn || in_array($fqcn, self::EXCLUDED, true)) {
continue;
}
$reflection = new ReflectionClass($fqcn);
// On ignore les classes abstraites et tout ce qui n'est pas une
// entite Doctrine (value objects, embeddables non mappes, etc.).
if ($reflection->isAbstract() || [] === $reflection->getAttributes(Entity::class)) {
continue;
}
self::assertTrue(
$reflection->implementsInterface(TimestampableInterface::class)
&& $reflection->implementsInterface(BlamableInterface::class),
sprintf(
'L\'entite %s doit implementer TimestampableInterface ET BlamableInterface '
.'(utiliser TimestampableBlamableTrait). Si c\'est un referentiel statique '
.'justifie, l\'ajouter dans EntitiesAreTimestampableBlamableTest::EXCLUDED.',
$fqcn,
),
);
}
}
/**
* Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du
* source, sans charger le fichier.
*/
private function extractFqcn(string $path): ?string
{
$source = file_get_contents($path);
if (false === $source) {
return null;
}
if (
1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
|| 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
) {
return null;
}
return trim($nsMatch[1]).'\\'.$classMatch[1];
}
}
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Tests\Shared\Infrastructure\Doctrine;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Infrastructure\Doctrine\TimestampableBlamableSubscriber;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Tests unitaires du TimestampableBlamableSubscriber.
*
* On exerce directement prePersist / preUpdate avec un EntityManager et une
* Security stubbes — aucun boot de kernel, aucun acces BDD. Les entites de test
* sont des fixtures internes (cf. bas de fichier).
*
* @internal
*/
final class TimestampableBlamableSubscriberTest extends TestCase
{
public function testPrePersistWithUser(): void
{
$user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
$entity = new FullAuditableFixture();
$subscriber->prePersist($this->prePersistArgs($entity));
// Les 4 colonnes sont remplies : dates posees, blame = user courant.
self::assertInstanceOf(DateTimeImmutable::class, $entity->getCreatedAt());
self::assertInstanceOf(DateTimeImmutable::class, $entity->getUpdatedAt());
self::assertSame($entity->getCreatedAt(), $entity->getUpdatedAt());
self::assertSame($user, $entity->getCreatedBy());
self::assertSame($user, $entity->getUpdatedBy());
}
public function testPrePersistWithoutUser(): void
{
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null));
$entity = new FullAuditableFixture();
$subscriber->prePersist($this->prePersistArgs($entity));
// Hors contexte HTTP (CLI / cron) : dates remplies, blame laisse a null.
self::assertInstanceOf(DateTimeImmutable::class, $entity->getCreatedAt());
self::assertInstanceOf(DateTimeImmutable::class, $entity->getUpdatedAt());
self::assertNull($entity->getCreatedBy());
self::assertNull($entity->getUpdatedBy());
}
public function testPreUpdate(): void
{
$user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
// On simule une entite deja persistee : createdAt fige dans le passe,
// createdBy positionne par une creation anterieure.
$createdAt = new DateTimeImmutable('2020-01-01 10:00:00');
$entity = new FullAuditableFixture();
$entity->setCreatedAt($createdAt);
$entity->setUpdatedAt($createdAt);
$subscriber->preUpdate($this->preUpdateArgs($entity));
// updatedAt avance, createdAt reste fige, updatedBy = user courant.
self::assertSame($createdAt, $entity->getCreatedAt());
self::assertGreaterThan($createdAt, $entity->getUpdatedAt());
self::assertSame($user, $entity->getUpdatedBy());
}
public function testPartialEntityTimestampableOnly(): void
{
$user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
$entity = new TimestampableOnlyFixture();
// Entite Timestampable mais NON Blamable : seules les dates sont posees,
// aucun appel de blame (et aucune erreur).
$subscriber->prePersist($this->prePersistArgs($entity));
self::assertInstanceOf(DateTimeImmutable::class, $entity->getCreatedAt());
self::assertInstanceOf(DateTimeImmutable::class, $entity->getUpdatedAt());
}
/**
* Security stubbee renvoyant l'utilisateur fourni (ou null).
*/
private function securityReturning(?UserInterface $user): Security
{
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn($user);
return $security;
}
private function prePersistArgs(object $entity): PrePersistEventArgs
{
return new PrePersistEventArgs($entity, $this->createStub(EntityManagerInterface::class));
}
private function preUpdateArgs(object $entity): PreUpdateEventArgs
{
$changeSet = [];
return new PreUpdateEventArgs($entity, $this->createStub(EntityManagerInterface::class), $changeSet);
}
}
/**
* Fixture interne : entite metier complete (Timestampable + Blamable) via le
* Trait reel teste.
*
* @internal
*/
final class FullAuditableFixture implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
}
/**
* Fixture interne : entite Timestampable seule (sans Blamable), pour verifier
* la dissociation des deux contrats par le Subscriber.
*
* @internal
*/
final class TimestampableOnlyFixture implements TimestampableInterface
{
private ?DateTimeImmutable $createdAt = null;
private ?DateTimeImmutable $updatedAt = null;
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): void
{
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
}